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:
110
apps/meteor/client/lib/2fa/overrideLoginMethod.ts
Normal file
110
apps/meteor/client/lib/2fa/overrideLoginMethod.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { Accounts } from 'meteor/accounts-base';
|
||||
|
||||
import type { MeteorErrorLike } from './types';
|
||||
import { isTotpInvalidError, isTotpMaxAttemptsError, isTotpRequiredError } from './utils';
|
||||
|
||||
export type LoginCallback = (error: MeteorErrorLike | undefined, result?: unknown) => void;
|
||||
|
||||
export const overrideLoginMethod = <TArgs extends any[]>(
|
||||
loginMethod: (...args: [...args: TArgs, cb: LoginCallback]) => void,
|
||||
loginArgs: TArgs,
|
||||
callback: LoginCallback | undefined,
|
||||
loginMethodTOTP: (...args: [...args: TArgs, code: string, cb: LoginCallback]) => void,
|
||||
) => {
|
||||
loginMethod(...loginArgs, async (error: MeteorErrorLike | undefined, result?: unknown) => {
|
||||
if (!isTotpRequiredError(error)) {
|
||||
callback?.(error);
|
||||
return error;
|
||||
}
|
||||
|
||||
const { process2faReturn } = await import('./process2faReturn');
|
||||
|
||||
try {
|
||||
await process2faReturn({
|
||||
error,
|
||||
result,
|
||||
emailOrUsername: typeof loginArgs[0] === 'string' ? loginArgs[0] : undefined,
|
||||
originalCallback: callback,
|
||||
onCode: (code: string) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
loginMethodTOTP(...loginArgs, code, (error: MeteorErrorLike | undefined, result?: unknown) => {
|
||||
if (!error) {
|
||||
callback?.(undefined, result);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTotpInvalidError(error)) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
Promise.all([import('../../../app/utils/lib/i18n'), import('../toast')]).then(([{ t }, { dispatchToastMessage }]) => {
|
||||
if (isTotpMaxAttemptsError(error)) {
|
||||
dispatchToastMessage({ type: 'error', message: t('totp-max-attempts') });
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
dispatchToastMessage({ type: 'error', message: t('Invalid_two_factor_code') });
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
callback?.(error as MeteorErrorLike);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const handleLogin = <TLoginFunction extends (...args: any[]) => Promise<any>>(
|
||||
login: TLoginFunction,
|
||||
loginWithTOTP: (...args: [...args: Parameters<TLoginFunction>, code: string]) => ReturnType<TLoginFunction>,
|
||||
) => {
|
||||
return (...args: [...loginArgs: Parameters<TLoginFunction>, callback?: LoginCallback]) => {
|
||||
const loginArgs = args.slice(0, -1) as Parameters<TLoginFunction>;
|
||||
const callback = args.slice(-1)[0] as LoginCallback | undefined;
|
||||
|
||||
return login(...loginArgs)
|
||||
.catch(async (error: MeteorErrorLike | undefined) => {
|
||||
if (!isTotpRequiredError(error)) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
const { process2faAsyncReturn } = await import('./process2faReturn');
|
||||
return process2faAsyncReturn({
|
||||
emailOrUsername: typeof loginArgs[0] === 'string' ? loginArgs[0] : undefined,
|
||||
error,
|
||||
onCode: (code: string) => loginWithTOTP(...loginArgs, code),
|
||||
});
|
||||
})
|
||||
.then((result: unknown) => callback?.(undefined, result))
|
||||
.catch((error: MeteorErrorLike | undefined) => {
|
||||
if (!isTotpInvalidError(error)) {
|
||||
callback?.(error);
|
||||
return;
|
||||
}
|
||||
|
||||
Promise.all([import('../../../app/utils/lib/i18n'), import('../toast')]).then(([{ t }, { dispatchToastMessage }]) => {
|
||||
dispatchToastMessage({ type: 'error', message: t('Invalid_two_factor_code') });
|
||||
callback?.(undefined);
|
||||
});
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export const callLoginMethod = (options: Omit<Accounts.LoginMethodOptions, 'userCallback'>) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
Accounts.callLoginMethod({
|
||||
...options,
|
||||
userCallback: (error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
172
apps/meteor/client/lib/2fa/process2faReturn.ts
Normal file
172
apps/meteor/client/lib/2fa/process2faReturn.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { SHA256 } from '@rocket.chat/sha256';
|
||||
import { imperativeModal } from '@rocket.chat/ui-client';
|
||||
import { lazy } from 'react';
|
||||
|
||||
import type { LoginCallback } from './overrideLoginMethod';
|
||||
import type { MeteorErrorLike } from './types';
|
||||
import { isTotpInvalidError, isTotpRequiredError } from './utils';
|
||||
import { getUser } from '../user';
|
||||
|
||||
const TwoFactorModal = lazy(() => import('../../components/TwoFactorModal'));
|
||||
|
||||
const twoFactorMethods = ['totp', 'email', 'password'] as const;
|
||||
|
||||
type TwoFactorMethod = (typeof twoFactorMethods)[number];
|
||||
|
||||
const isTwoFactorMethod = (method: string): method is TwoFactorMethod => twoFactorMethods.includes(method as TwoFactorMethod);
|
||||
|
||||
const hasRequiredTwoFactorMethod = (
|
||||
error: MeteorErrorLike,
|
||||
): error is MeteorErrorLike & { details: { method: TwoFactorMethod; emailOrUsername?: string } } => {
|
||||
const { details } = error;
|
||||
|
||||
return (
|
||||
typeof details === 'object' &&
|
||||
details !== null &&
|
||||
typeof (details as { method: unknown }).method === 'string' &&
|
||||
isTwoFactorMethod((details as { method: string }).method)
|
||||
);
|
||||
};
|
||||
|
||||
function assertModalProps(props: {
|
||||
method: TwoFactorMethod;
|
||||
emailOrUsername?: string;
|
||||
}): asserts props is
|
||||
{ method: 'totp' | 'password'; invalidAttempt?: boolean } | { method: 'email'; emailOrUsername: string; invalidAttempt?: boolean } {
|
||||
if (props.method === 'email' && typeof props.emailOrUsername !== 'string') {
|
||||
throw new Error('Invalid Two Factor method');
|
||||
}
|
||||
}
|
||||
|
||||
const getProps = (
|
||||
method: 'totp' | 'email' | 'password',
|
||||
emailOrUsername?: { username: string } | { email: string } | { id: string } | string,
|
||||
) => {
|
||||
switch (method) {
|
||||
case 'totp':
|
||||
return { method };
|
||||
case 'email':
|
||||
return {
|
||||
method,
|
||||
emailOrUsername: typeof emailOrUsername === 'string' ? emailOrUsername : getUser()?.username,
|
||||
};
|
||||
case 'password':
|
||||
return { method };
|
||||
}
|
||||
};
|
||||
|
||||
export async function process2faReturn({
|
||||
error,
|
||||
result,
|
||||
originalCallback,
|
||||
onCode,
|
||||
emailOrUsername,
|
||||
}: {
|
||||
error: MeteorErrorLike | undefined;
|
||||
result: unknown;
|
||||
originalCallback: LoginCallback | undefined;
|
||||
onCode: (code: string, method: string) => void | Promise<void>;
|
||||
emailOrUsername: { username: string } | { email: string } | { id: string } | string | null | undefined;
|
||||
}): Promise<void> {
|
||||
if (!(isTotpRequiredError(error) || isTotpInvalidError(error)) || !hasRequiredTwoFactorMethod(error)) {
|
||||
originalCallback?.(error, result);
|
||||
return;
|
||||
}
|
||||
|
||||
const props = {
|
||||
...getProps(error.details.method, emailOrUsername || error.details.emailOrUsername),
|
||||
};
|
||||
|
||||
const validateCode = async (code: string, method: string): Promise<void> => {
|
||||
await onCode(code, method);
|
||||
};
|
||||
|
||||
await invokeTwoFactorModal(props, validateCode);
|
||||
}
|
||||
|
||||
export async function process2faAsyncReturn<TResult>({
|
||||
error,
|
||||
onCode,
|
||||
emailOrUsername,
|
||||
}: {
|
||||
error: unknown;
|
||||
onCode: (code: string, method: string) => TResult | Promise<TResult>;
|
||||
emailOrUsername: string | null | undefined;
|
||||
}): Promise<TResult> {
|
||||
// if the promise is rejected, we need to check if it's a 2fa error
|
||||
// if it's not a 2fa error, we reject the promise
|
||||
if (!(isTotpRequiredError(error) || isTotpInvalidError(error)) || !hasRequiredTwoFactorMethod(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const props = {
|
||||
method: error.details.method,
|
||||
emailOrUsername: emailOrUsername || error.details.emailOrUsername || getUser()?.username,
|
||||
};
|
||||
|
||||
assertModalProps(props);
|
||||
|
||||
let result: TResult | undefined;
|
||||
|
||||
const validateCode = async (code: string, method: string): Promise<void> => {
|
||||
result = await onCode(code, method);
|
||||
};
|
||||
|
||||
await invokeTwoFactorModal(props, validateCode);
|
||||
|
||||
if (result === undefined) {
|
||||
throw new Error('Unexpected error: result is undefined');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export const invokeTwoFactorModal = async (
|
||||
props: {
|
||||
method: 'totp' | 'email' | 'password';
|
||||
emailOrUsername?: string | undefined;
|
||||
invalidAttempt?: boolean;
|
||||
},
|
||||
validateCode?: (code: string, method: string) => Promise<void>,
|
||||
) => {
|
||||
const { invalidAttempt, ...restProps } = props;
|
||||
assertModalProps(restProps);
|
||||
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
let isResolved = false;
|
||||
let isClosed = false;
|
||||
|
||||
imperativeModal.open({
|
||||
component: TwoFactorModal,
|
||||
props: {
|
||||
...restProps,
|
||||
onConfirm: async (code: string, method: string): Promise<void> => {
|
||||
const actualCode = method === 'password' ? SHA256(code) : code;
|
||||
if (validateCode) {
|
||||
await validateCode(actualCode, method);
|
||||
}
|
||||
isResolved = true;
|
||||
imperativeModal.close();
|
||||
resolve(actualCode);
|
||||
},
|
||||
onClose: (): void => {
|
||||
if (isClosed) {
|
||||
return;
|
||||
}
|
||||
isClosed = true;
|
||||
imperativeModal.close();
|
||||
if (!isResolved) {
|
||||
Promise.all([import('../../../app/utils/lib/i18n'), import('../toast')]).then(([{ t }, { dispatchToastMessage }]) => {
|
||||
dispatchToastMessage({
|
||||
type: 'error',
|
||||
message: t('Two-factor_authentication_cancelled'),
|
||||
});
|
||||
});
|
||||
reject(new Error('totp-canceled'));
|
||||
}
|
||||
},
|
||||
...(invalidAttempt && { invalidAttempt }),
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
9
apps/meteor/client/lib/2fa/types.ts
Normal file
9
apps/meteor/client/lib/2fa/types.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
// Structural shape covering both Meteor.Error and Meteor.TypedError. The 2FA
|
||||
// helpers only narrow errors by `error` / `errorType` / `details` — they never
|
||||
// instanceof — so a local type avoids pulling `meteor/meteor` for type info.
|
||||
export type MeteorErrorLike = Error & {
|
||||
error?: unknown;
|
||||
errorType?: unknown;
|
||||
reason?: string;
|
||||
details?: unknown;
|
||||
};
|
||||
20
apps/meteor/client/lib/2fa/utils.ts
Normal file
20
apps/meteor/client/lib/2fa/utils.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { MeteorErrorLike } from './types';
|
||||
|
||||
export const isTotpRequiredError = (
|
||||
error: unknown,
|
||||
): error is MeteorErrorLike & ({ error: 'totp-required' } | { errorType: 'totp-required' }) =>
|
||||
typeof error === 'object' &&
|
||||
((error as { error?: unknown } | undefined)?.error === 'totp-required' ||
|
||||
(error as { errorType?: unknown } | undefined)?.errorType === 'totp-required');
|
||||
|
||||
export const isTotpInvalidError = (
|
||||
error: unknown,
|
||||
): error is MeteorErrorLike & ({ error: 'totp-invalid' } | { errorType: 'totp-invalid' }) =>
|
||||
(error as { error?: unknown } | undefined)?.error === 'totp-invalid' ||
|
||||
(error as { errorType?: unknown } | undefined)?.errorType === 'totp-invalid';
|
||||
|
||||
export const isTotpMaxAttemptsError = (
|
||||
error: unknown,
|
||||
): error is MeteorErrorLike & ({ error: 'totp-max-attempts' } | { errorType: 'totp-max-attempts' }) =>
|
||||
(error as { error?: unknown } | undefined)?.error === 'totp-max-attempts' ||
|
||||
(error as { errorType?: unknown } | undefined)?.errorType === 'totp-max-attempts';
|
||||
34
apps/meteor/client/lib/AccountBox.ts
Normal file
34
apps/meteor/client/lib/AccountBox.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { IUIActionButton, IUActionButtonWhen } from '@rocket.chat/apps-engine/definition/ui/IUIActionButtonDescriptor';
|
||||
import type { UserStatus } from '@rocket.chat/core-typings';
|
||||
import type { Keys as IconName } from '@rocket.chat/icons';
|
||||
import type { TranslationKey, LocationPathname } from '@rocket.chat/ui-contexts';
|
||||
|
||||
import { sdk } from './SDKClient';
|
||||
|
||||
export interface IAppAccountBoxItem extends IUIActionButton {
|
||||
name: string;
|
||||
icon?: string;
|
||||
href?: string;
|
||||
sideNav?: string;
|
||||
isAppButtonItem?: boolean;
|
||||
subItems?: [IAppAccountBoxItem];
|
||||
when?: Omit<IUActionButtonWhen, 'roomTypes' | 'messageActionContext'>;
|
||||
}
|
||||
|
||||
export type AccountBoxItem = {
|
||||
name: TranslationKey;
|
||||
icon: IconName;
|
||||
href: LocationPathname;
|
||||
sideNav?: string;
|
||||
condition: () => boolean;
|
||||
};
|
||||
|
||||
export const isAppAccountBoxItem = (item: IAppAccountBoxItem | AccountBoxItem): item is IAppAccountBoxItem => 'isAppButtonItem' in item;
|
||||
|
||||
class AccountBoxBase {
|
||||
public setStatus(status: UserStatus, statusText?: string): any {
|
||||
return sdk.rest.post('/v1/users.setStatus', { status, message: statusText });
|
||||
}
|
||||
}
|
||||
|
||||
export const AccountBox = new AccountBoxBase();
|
||||
313
apps/meteor/client/lib/ActionManager.ts
Normal file
313
apps/meteor/client/lib/ActionManager.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
import type { DistributiveOmit } from '@rocket.chat/core-typings';
|
||||
import { Emitter } from '@rocket.chat/emitter';
|
||||
import { Random } from '@rocket.chat/random';
|
||||
import { imperativeModal } from '@rocket.chat/ui-client';
|
||||
import type { RouterContext, IActionManager } from '@rocket.chat/ui-contexts';
|
||||
import type * as UiKit from '@rocket.chat/ui-kit';
|
||||
import { t } from 'i18next';
|
||||
import type { ContextType } from 'react';
|
||||
import { lazy } from 'react';
|
||||
|
||||
import { sdk } from './SDKClient';
|
||||
import * as banners from './banners';
|
||||
import { UiKitTriggerTimeoutError } from './errors/UiKitTriggerTimeoutError';
|
||||
import { dispatchToastMessage } from './toast';
|
||||
import { exhaustiveCheck } from '../../lib/utils/exhaustiveCheck';
|
||||
|
||||
const UiKitModal = lazy(() => import('../views/modal/uikit/UiKitModal'));
|
||||
|
||||
export class ActionManager implements IActionManager {
|
||||
protected static TRIGGER_TIMEOUT = 5000;
|
||||
|
||||
protected static TRIGGER_TIMEOUT_ERROR = 'TRIGGER_TIMEOUT_ERROR';
|
||||
|
||||
protected events = new Emitter<{ busy: { busy: boolean }; [viewId: string]: any }>();
|
||||
|
||||
protected appIdByTriggerId = new Map<string, string | undefined>();
|
||||
|
||||
protected viewInstances = new Map<
|
||||
string,
|
||||
{
|
||||
payload?: {
|
||||
view: UiKit.ContextualBarView;
|
||||
};
|
||||
close: () => void;
|
||||
}
|
||||
>();
|
||||
|
||||
public constructor(protected router: ContextType<typeof RouterContext>) {}
|
||||
|
||||
protected invalidateTriggerId(id: string) {
|
||||
const appId = this.appIdByTriggerId.get(id);
|
||||
this.appIdByTriggerId.delete(id);
|
||||
return appId;
|
||||
}
|
||||
|
||||
public on(viewId: string, listener: (data: any) => void): void;
|
||||
|
||||
public on(eventName: 'busy', listener: ({ busy }: { busy: boolean }) => void): void;
|
||||
|
||||
public on(eventName: string, listener: (data: any) => void) {
|
||||
return this.events.on(eventName, listener);
|
||||
}
|
||||
|
||||
public off(viewId: string, listener: (data: any) => any): void;
|
||||
|
||||
public off(eventName: 'busy', listener: ({ busy }: { busy: boolean }) => void): void;
|
||||
|
||||
public off(eventName: string, listener: (data: any) => void) {
|
||||
return this.events.off(eventName, listener);
|
||||
}
|
||||
|
||||
public notifyBusy() {
|
||||
this.events.emit('busy', { busy: true });
|
||||
}
|
||||
|
||||
public notifyIdle() {
|
||||
this.events.emit('busy', { busy: false });
|
||||
}
|
||||
|
||||
public generateTriggerId(appId: string | undefined) {
|
||||
const triggerId = Random.id();
|
||||
this.appIdByTriggerId.set(triggerId, appId);
|
||||
setTimeout(() => this.invalidateTriggerId(triggerId), ActionManager.TRIGGER_TIMEOUT);
|
||||
return triggerId;
|
||||
}
|
||||
|
||||
public async emitInteraction(appId: string, userInteraction: DistributiveOmit<UiKit.UserInteraction, 'triggerId'>) {
|
||||
const triggerId = this.generateTriggerId(appId);
|
||||
|
||||
return this.runWithTimeout(
|
||||
async () => {
|
||||
let interaction: UiKit.ServerInteraction | undefined;
|
||||
|
||||
try {
|
||||
interaction = (await sdk.rest.post(`/apps/ui.interaction/${appId}`, {
|
||||
...userInteraction,
|
||||
triggerId,
|
||||
})) as UiKit.ServerInteraction;
|
||||
|
||||
this.handleServerInteraction(interaction);
|
||||
} finally {
|
||||
switch (userInteraction.type) {
|
||||
case 'viewSubmit':
|
||||
if (!!interaction && !['errors', 'modal.update', 'contextual_bar.update'].includes(interaction.type))
|
||||
this.disposeView(userInteraction.viewId);
|
||||
break;
|
||||
|
||||
case 'viewClosed':
|
||||
if (!!interaction && interaction.type !== 'errors') this.disposeView(userInteraction.payload.viewId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
{ triggerId, appId, ...('viewId' in userInteraction ? { viewId: userInteraction.viewId } : {}) },
|
||||
);
|
||||
}
|
||||
|
||||
protected async runWithTimeout<T>(task: () => Promise<T>, details: { triggerId: string; appId: string; viewId?: string }) {
|
||||
this.notifyBusy();
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
try {
|
||||
const taskPromise = task();
|
||||
const timeoutPromise = new Promise<T>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
reject(new UiKitTriggerTimeoutError('Timeout', details));
|
||||
}, ActionManager.TRIGGER_TIMEOUT);
|
||||
});
|
||||
|
||||
return await Promise.race([taskPromise, timeoutPromise]);
|
||||
} catch (error) {
|
||||
if (error instanceof UiKitTriggerTimeoutError) {
|
||||
dispatchToastMessage({
|
||||
type: 'error',
|
||||
message: t('UIKit_Interaction_Timeout'),
|
||||
});
|
||||
if (details.viewId) {
|
||||
this.disposeView(details.viewId);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
this.notifyIdle();
|
||||
}
|
||||
}
|
||||
|
||||
public handleServerInteraction(interaction: UiKit.ServerInteraction): UiKit.ServerInteraction['type'] | undefined {
|
||||
const { triggerId } = interaction;
|
||||
|
||||
const appId = this.invalidateTriggerId(triggerId);
|
||||
if (!appId) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (interaction.type) {
|
||||
case 'errors': {
|
||||
const { type, triggerId, viewId, appId, errors } = interaction;
|
||||
this.events.emit(interaction.viewId, {
|
||||
type,
|
||||
triggerId,
|
||||
viewId,
|
||||
appId,
|
||||
errors,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'modal.open': {
|
||||
const { view } = interaction;
|
||||
this.openModal(view);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'modal.update':
|
||||
case 'contextual_bar.update': {
|
||||
const { type, triggerId, appId, view } = interaction;
|
||||
this.events.emit(view.id, {
|
||||
type,
|
||||
triggerId,
|
||||
viewId: view.id,
|
||||
appId,
|
||||
view,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'modal.close': {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'banner.open': {
|
||||
const { type, triggerId, ...view } = interaction;
|
||||
this.openBanner(view);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'banner.update': {
|
||||
const { type, triggerId, appId, view } = interaction;
|
||||
this.events.emit(view.viewId, {
|
||||
type,
|
||||
triggerId,
|
||||
viewId: view.viewId,
|
||||
appId,
|
||||
view,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'banner.close': {
|
||||
const { viewId } = interaction;
|
||||
this.disposeView(viewId);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'contextual_bar.open': {
|
||||
const { view } = interaction;
|
||||
this.openContextualBar(view);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'contextual_bar.close': {
|
||||
const { view } = interaction;
|
||||
this.disposeView(view.id);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
exhaustiveCheck(interaction);
|
||||
}
|
||||
|
||||
return interaction.type;
|
||||
}
|
||||
|
||||
public getInteractionPayloadByViewId(viewId: UiKit.ContextualBarView['id']) {
|
||||
if (!viewId) {
|
||||
throw new Error('No viewId provided when checking for `user interaction payload`');
|
||||
}
|
||||
|
||||
return this.viewInstances.get(viewId)?.payload;
|
||||
}
|
||||
|
||||
public openView(surface: 'modal', view: UiKit.ModalView): void;
|
||||
|
||||
public openView(surface: 'banner', view: UiKit.BannerView): void;
|
||||
|
||||
public openView(surface: 'contextual_bar', view: UiKit.ContextualBarView): void;
|
||||
|
||||
public openView(surface: string, view: UiKit.View) {
|
||||
switch (surface) {
|
||||
case 'modal':
|
||||
this.openModal(view as UiKit.ModalView);
|
||||
break;
|
||||
|
||||
case 'banner':
|
||||
this.openBanner(view as UiKit.BannerView);
|
||||
break;
|
||||
|
||||
case 'contextual_bar':
|
||||
this.openContextualBar(view as UiKit.ContextualBarView);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private openModal(view: UiKit.ModalView) {
|
||||
const instance = imperativeModal.open({
|
||||
component: UiKitModal,
|
||||
props: {
|
||||
key: view.id,
|
||||
initialView: view,
|
||||
},
|
||||
});
|
||||
|
||||
this.viewInstances.set(view.id, {
|
||||
close: () => {
|
||||
instance.close();
|
||||
this.viewInstances.delete(view.id);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private openBanner(view: UiKit.BannerView) {
|
||||
banners.open(view);
|
||||
this.viewInstances.set(view.viewId, {
|
||||
close: () => {
|
||||
banners.closeById(view.viewId);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private openContextualBar(view: UiKit.ContextualBarView) {
|
||||
this.viewInstances.set(view.id, {
|
||||
payload: {
|
||||
view,
|
||||
},
|
||||
close: () => {
|
||||
this.viewInstances.delete(view.id);
|
||||
},
|
||||
});
|
||||
|
||||
const routeName = this.router.getRouteName();
|
||||
const routeParams = this.router.getRouteParameters();
|
||||
|
||||
if (!routeName) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.router.navigate({
|
||||
name: routeName,
|
||||
params: {
|
||||
...routeParams,
|
||||
tab: 'app',
|
||||
context: view.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public disposeView(viewId: UiKit.ModalView['id']) {
|
||||
const instance = this.viewInstances.get(viewId);
|
||||
instance?.close?.();
|
||||
this.viewInstances.delete(viewId);
|
||||
}
|
||||
}
|
||||
61
apps/meteor/client/lib/AudioEncoder.ts
Normal file
61
apps/meteor/client/lib/AudioEncoder.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Emitter } from '@rocket.chat/emitter';
|
||||
|
||||
import { absoluteUrl } from './absoluteUrl';
|
||||
|
||||
export class AudioEncoder extends Emitter {
|
||||
private worker: Worker;
|
||||
|
||||
private scriptNode: ScriptProcessorNode;
|
||||
|
||||
constructor(source: MediaStreamAudioSourceNode, { bufferLen = 4096, numChannels = 1, bitRate = 32 } = {}) {
|
||||
super();
|
||||
|
||||
const workerPath = absoluteUrl('workers/mp3-encoder/index.js');
|
||||
|
||||
this.worker = new Worker(workerPath);
|
||||
this.worker.onmessage = this.handleWorkerMessage;
|
||||
|
||||
this.worker.postMessage({
|
||||
command: 'init',
|
||||
config: {
|
||||
sampleRate: source.context.sampleRate,
|
||||
numChannels,
|
||||
bitRate,
|
||||
},
|
||||
});
|
||||
|
||||
this.scriptNode = source.context.createScriptProcessor(bufferLen, numChannels, numChannels);
|
||||
this.scriptNode.onaudioprocess = this.handleAudioProcess;
|
||||
|
||||
source.connect(this.scriptNode);
|
||||
this.scriptNode.connect(source.context.destination);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.worker.postMessage({ command: 'finish' });
|
||||
}
|
||||
|
||||
handleWorkerMessage = (event: MessageEvent) => {
|
||||
switch (event.data.command) {
|
||||
case 'end': {
|
||||
// prepend mp3 magic number to the buffer
|
||||
const magicNoPrefix = new Int8Array([73, 68, 51, 3, 0, 0, 0, 0, 0, 0]);
|
||||
const bufferWithMagicNo = [magicNoPrefix, ...event.data.buffer];
|
||||
const blob = new Blob(bufferWithMagicNo, { type: 'audio/mpeg' });
|
||||
this.emit('encoded', blob);
|
||||
this.worker.terminate();
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
handleAudioProcess = (event: AudioProcessingEvent) => {
|
||||
for (let channel = 0; channel < event.inputBuffer.numberOfChannels; channel++) {
|
||||
const buffer = event.inputBuffer.getChannelData(channel);
|
||||
this.worker.postMessage({
|
||||
command: 'encode',
|
||||
buffer,
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
91
apps/meteor/client/lib/AudioRecorder.ts
Normal file
91
apps/meteor/client/lib/AudioRecorder.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { AudioEncoder } from './AudioEncoder';
|
||||
import { settings } from './settings';
|
||||
|
||||
export class AudioRecorder {
|
||||
private audioContext: AudioContext | undefined;
|
||||
|
||||
private stream: MediaStream | undefined;
|
||||
|
||||
private encoder: AudioEncoder | undefined;
|
||||
|
||||
isSupported() {
|
||||
return Boolean(navigator.mediaDevices?.getUserMedia) && Boolean(AudioContext);
|
||||
}
|
||||
|
||||
createAudioContext() {
|
||||
if (this.audioContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.audioContext = new AudioContext();
|
||||
}
|
||||
|
||||
destroyAudioContext() {
|
||||
if (!this.audioContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
void this.audioContext.close();
|
||||
delete this.audioContext;
|
||||
}
|
||||
|
||||
async createStream() {
|
||||
if (this.stream) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
}
|
||||
|
||||
destroyStream() {
|
||||
if (!this.stream) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.stream.getAudioTracks().forEach((track) => track.stop());
|
||||
delete this.stream;
|
||||
}
|
||||
|
||||
async createEncoder() {
|
||||
if (this.encoder || !this.audioContext || !this.stream) {
|
||||
return;
|
||||
}
|
||||
|
||||
const input = this.audioContext?.createMediaStreamSource(this.stream);
|
||||
this.encoder = new AudioEncoder(input, { bitRate: settings.peek('Message_Audio_bitRate') || 32 });
|
||||
}
|
||||
|
||||
destroyEncoder() {
|
||||
if (!this.encoder) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.encoder.close();
|
||||
delete this.encoder;
|
||||
}
|
||||
|
||||
async start(cb?: (this: this, done: boolean) => void) {
|
||||
try {
|
||||
this.createAudioContext();
|
||||
await this.createStream();
|
||||
await this.createEncoder();
|
||||
cb?.call(this, true);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
this.destroyEncoder();
|
||||
this.destroyStream();
|
||||
this.destroyAudioContext();
|
||||
cb?.call(this, false);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
stop(cb: (data: Blob) => void) {
|
||||
this.encoder?.on('encoded', cb);
|
||||
this.encoder?.close();
|
||||
|
||||
this.destroyEncoder();
|
||||
this.destroyStream();
|
||||
this.destroyAudioContext();
|
||||
}
|
||||
}
|
||||
191
apps/meteor/client/lib/ChatMessages.ts
Normal file
191
apps/meteor/client/lib/ChatMessages.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import type { DeepWritable, IMessage, IRoom, IUser } from '@rocket.chat/core-typings';
|
||||
import { isVideoConfMessage } from '@rocket.chat/core-typings';
|
||||
import type { IActionManager } from '@rocket.chat/ui-contexts';
|
||||
|
||||
import { CurrentEditingMessage } from './CurrentEditingMessage';
|
||||
import { UserAction } from './UserAction';
|
||||
import type { ChatAPI, ComposerAPI, DataAPI } from './chats/ChatAPI';
|
||||
import { createDataAPI } from './chats/data';
|
||||
import { processMessageEditing } from './chats/flows/processMessageEditing';
|
||||
import { processMessageUploads } from './chats/flows/processMessageUploads';
|
||||
import { processSetReaction } from './chats/flows/processSetReaction';
|
||||
import { processSlashCommand } from './chats/flows/processSlashCommand';
|
||||
import { processTooLongMessage } from './chats/flows/processTooLongMessage';
|
||||
import { replyBroadcast } from './chats/flows/replyBroadcast';
|
||||
import { requestMessageDeletion } from './chats/flows/requestMessageDeletion';
|
||||
import { sendMessage } from './chats/flows/sendMessage';
|
||||
import { uploadFiles } from './chats/flows/uploadFiles';
|
||||
import { ReadStateManager } from './chats/readStateManager';
|
||||
import { setHighlightMessage } from '../views/room/MessageList/providers/messageHighlightSubscription';
|
||||
|
||||
export class ChatMessages implements ChatAPI {
|
||||
public uid: string | undefined;
|
||||
|
||||
public tmid?: IMessage['_id'];
|
||||
|
||||
public composer: ComposerAPI | undefined;
|
||||
|
||||
public setComposerAPI = (composer?: ComposerAPI): void => {
|
||||
this.composer?.release();
|
||||
this.composer = composer;
|
||||
};
|
||||
|
||||
public data: DataAPI;
|
||||
|
||||
public currentEditingMessage: CurrentEditingMessage;
|
||||
|
||||
public readStateManager: ReadStateManager;
|
||||
|
||||
public ActionManager: any;
|
||||
|
||||
public emojiPicker: {
|
||||
open(el: Element, cb: (emoji: string) => void): void;
|
||||
close(): void;
|
||||
};
|
||||
|
||||
public action: {
|
||||
start(action: 'typing'): Promise<void> | void;
|
||||
stop(action: 'typing' | 'recording' | 'uploading' | 'playing'): Promise<void> | void;
|
||||
performContinuously(action: 'recording' | 'uploading' | 'playing'): Promise<void> | void;
|
||||
};
|
||||
|
||||
public messageEditing: ChatAPI['messageEditing'] = {
|
||||
toPreviousMessage: async () => {
|
||||
if (!this.composer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mid = this.currentEditingMessage.getMID();
|
||||
|
||||
if (!mid) {
|
||||
let lastMessage = await this.data.findPreviousOwnMessage();
|
||||
|
||||
// Videoconf messages should not be edited
|
||||
if (lastMessage && isVideoConfMessage(lastMessage)) {
|
||||
lastMessage = await this.data.findPreviousOwnMessage(lastMessage);
|
||||
}
|
||||
|
||||
if (lastMessage) {
|
||||
await this.data.saveDraft(undefined, this.composer.text);
|
||||
await this.messageEditing.editMessage(lastMessage);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const currentMessage = await this.data.findMessageByID(mid);
|
||||
let previousMessage = currentMessage ? await this.data.findPreviousOwnMessage(currentMessage) : undefined;
|
||||
|
||||
// Videoconf messages should not be edited
|
||||
if (previousMessage && isVideoConfMessage(previousMessage)) {
|
||||
previousMessage = await this.data.findPreviousOwnMessage(previousMessage);
|
||||
}
|
||||
|
||||
if (previousMessage) {
|
||||
await this.messageEditing.editMessage(previousMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.currentEditingMessage.cancel();
|
||||
await this.currentEditingMessage.stop();
|
||||
},
|
||||
toNextMessage: async () => {
|
||||
const mid = this.currentEditingMessage.getMID();
|
||||
|
||||
if (!this.composer || !mid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentMessage = await this.data.findMessageByID(mid);
|
||||
let nextMessage = currentMessage ? await this.data.findNextOwnMessage(currentMessage) : undefined;
|
||||
|
||||
// Videoconf messages should not be edited
|
||||
if (nextMessage && isVideoConfMessage(nextMessage)) {
|
||||
nextMessage = await this.data.findNextOwnMessage(nextMessage);
|
||||
}
|
||||
|
||||
if (nextMessage) {
|
||||
await this.messageEditing.editMessage(nextMessage, { cursorAtStart: true });
|
||||
return;
|
||||
}
|
||||
|
||||
await this.currentEditingMessage.cancel();
|
||||
await this.currentEditingMessage.stop();
|
||||
},
|
||||
editMessage: async (message: IMessage, { cursorAtStart = false }: { cursorAtStart?: boolean } = {}) => {
|
||||
this.composer?.uploads.clear();
|
||||
const text = (await this.data.getDraft(message._id)) || message.attachments?.[0]?.description || message.msg;
|
||||
|
||||
await this.currentEditingMessage.stop();
|
||||
|
||||
if (!this.composer || !(await this.data.canUpdateMessage(message))) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentEditingMessage.setMID(message._id);
|
||||
setHighlightMessage(message._id);
|
||||
this.composer.setEditingMode(true);
|
||||
|
||||
this.composer.setText(text);
|
||||
cursorAtStart && this.composer.setCursorToStart();
|
||||
!cursorAtStart && this.composer.setCursorToEnd();
|
||||
this.composer.focus();
|
||||
},
|
||||
};
|
||||
|
||||
public flows: DeepWritable<ChatAPI['flows']>;
|
||||
|
||||
public constructor(params: { rid: IRoom['_id']; tmid?: IMessage['_id']; uid: IUser['_id'] | undefined; actionManager: IActionManager }) {
|
||||
const { rid, tmid } = params;
|
||||
this.tmid = tmid;
|
||||
this.uid = params.uid;
|
||||
this.data = createDataAPI({ rid, tmid });
|
||||
this.ActionManager = params.actionManager;
|
||||
this.currentEditingMessage = new CurrentEditingMessage(this);
|
||||
|
||||
const unimplemented = () => {
|
||||
throw new Error('Flow is not implemented');
|
||||
};
|
||||
|
||||
this.readStateManager = new ReadStateManager(rid);
|
||||
|
||||
this.emojiPicker = {
|
||||
open: unimplemented,
|
||||
close: unimplemented,
|
||||
};
|
||||
|
||||
this.action = {
|
||||
start: async (action: 'typing') => {
|
||||
UserAction.start(params.rid, `user-${action}`, { tmid: params.tmid });
|
||||
},
|
||||
performContinuously: async (action: 'recording' | 'uploading' | 'playing') => {
|
||||
UserAction.performContinuously(params.rid, `user-${action}`, { tmid: params.tmid });
|
||||
},
|
||||
stop: async (action: 'typing' | 'recording' | 'uploading' | 'playing') => {
|
||||
UserAction.stop(params.rid, `user-${action}`, { tmid: params.tmid });
|
||||
},
|
||||
};
|
||||
|
||||
this.flows = {
|
||||
uploadFiles: uploadFiles.bind(null, this),
|
||||
sendMessage: sendMessage.bind(this, this),
|
||||
processSlashCommand: processSlashCommand.bind(null, this),
|
||||
processTooLongMessage: processTooLongMessage.bind(null, this),
|
||||
processMessageEditing: processMessageEditing.bind(null, this),
|
||||
processMessageUploads: processMessageUploads.bind(null, this),
|
||||
processSetReaction: processSetReaction.bind(null, this),
|
||||
requestMessageDeletion: requestMessageDeletion.bind(this, this),
|
||||
replyBroadcast: replyBroadcast.bind(null, this),
|
||||
};
|
||||
}
|
||||
|
||||
public async release() {
|
||||
if (this.currentEditingMessage.getMID()) {
|
||||
if (!this.tmid) {
|
||||
await this.currentEditingMessage.cancel();
|
||||
await this.currentEditingMessage.stop();
|
||||
}
|
||||
this.composer?.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
114
apps/meteor/client/lib/CurrentEditingMessage.ts
Normal file
114
apps/meteor/client/lib/CurrentEditingMessage.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import type { ChatAPI } from './chats/ChatAPI';
|
||||
import { clearHighlightMessage } from '../views/room/MessageList/providers/messageHighlightSubscription';
|
||||
|
||||
export class CurrentEditingMessage {
|
||||
private lock = false;
|
||||
|
||||
private mid?: string;
|
||||
|
||||
private queue: {
|
||||
resolve: (release: () => void) => void;
|
||||
}[] = [];
|
||||
|
||||
private chat: ChatAPI;
|
||||
|
||||
constructor(chat: ChatAPI) {
|
||||
this.chat = chat;
|
||||
}
|
||||
|
||||
public reset = async () => {
|
||||
return this.runExclusive(async () => {
|
||||
if (!this.chat.composer || !this.mid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const message = await this.chat.data.findMessageByID(this.mid);
|
||||
|
||||
if (this.chat.composer.text !== message?.msg) {
|
||||
this.chat.composer.setText(message?.msg ?? '');
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
public stop = async () => {
|
||||
await this.runExclusive(async () => {
|
||||
if (!this.chat.composer || !this.mid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = await this.chat.data.findMessageByID(this.mid);
|
||||
const draft = this.chat.composer.text;
|
||||
|
||||
if (draft === message?.msg) {
|
||||
await this.chat.data.discardDraft(this.mid);
|
||||
} else {
|
||||
await this.chat.data.saveDraft(this.mid, (await this.chat.data.getDraft(this.mid)) || draft);
|
||||
}
|
||||
|
||||
this.chat.composer.setEditingMode(false);
|
||||
this.mid = undefined;
|
||||
clearHighlightMessage();
|
||||
});
|
||||
};
|
||||
|
||||
public cancel = async () => {
|
||||
await this.runExclusive(async () => {
|
||||
if (!this.mid) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.chat.data.discardDraft(this.mid);
|
||||
this.chat.composer?.setText((await this.chat.data.getDraft(undefined)) ?? '');
|
||||
});
|
||||
};
|
||||
|
||||
private acquire = async () => {
|
||||
return new Promise<() => void>((resolve) => {
|
||||
this.queue.push({ resolve });
|
||||
this.dispatch();
|
||||
});
|
||||
};
|
||||
|
||||
private dispatch() {
|
||||
if (this.lock) {
|
||||
return;
|
||||
}
|
||||
|
||||
const next = this.queue.shift();
|
||||
|
||||
if (!next) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lock = true;
|
||||
next.resolve(this.buildRelease());
|
||||
}
|
||||
|
||||
private buildRelease = () => {
|
||||
return async () => {
|
||||
this.lock = false;
|
||||
this.dispatch();
|
||||
};
|
||||
};
|
||||
|
||||
public getMID() {
|
||||
return this.mid;
|
||||
}
|
||||
|
||||
public setMID(mid: string) {
|
||||
this.mid = mid;
|
||||
}
|
||||
|
||||
private async runExclusive<T>(callback: () => Promise<T>) {
|
||||
const release = await this.acquire();
|
||||
|
||||
try {
|
||||
return await callback();
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
}
|
||||
321
apps/meteor/client/lib/LegacyRoomManager.ts
Normal file
321
apps/meteor/client/lib/LegacyRoomManager.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
import type { IMessage, IRoom } from '@rocket.chat/core-typings';
|
||||
import { Emitter } from '@rocket.chat/emitter';
|
||||
import { createPredicateFromFilter } from '@rocket.chat/mongo-adapter';
|
||||
import { clientCallbacks } from '@rocket.chat/ui-client';
|
||||
import type { Filter } from 'mongodb';
|
||||
|
||||
import { RoomManager } from './RoomManager';
|
||||
import { Messages, Subscriptions } from '../stores';
|
||||
import { upsertMessage, RoomHistoryManager } from './RoomHistoryManager';
|
||||
import { sdk } from './SDKClient';
|
||||
import { roomCoordinator } from './rooms/roomCoordinator';
|
||||
import { fireGlobalEvent } from './utils/fireGlobalEvent';
|
||||
import { getConfig } from './utils/getConfig';
|
||||
import { modifyMessageOnFilesDelete } from './utils/modifyMessageOnFilesDelete';
|
||||
|
||||
const maxRoomsOpen = parseInt(getConfig('maxRoomsOpen') ?? '5') || 5;
|
||||
|
||||
type ListenRoomPropsByRidProps = keyof OpenedRoom;
|
||||
type ListenRoomPropsByRidPropsEvent = `${string}/${ListenRoomPropsByRidProps}`;
|
||||
|
||||
const listener = new Emitter<{
|
||||
[key in ListenRoomPropsByRidPropsEvent]: undefined;
|
||||
}>();
|
||||
|
||||
type OpenedRoom = {
|
||||
typeName: string;
|
||||
rid: IRoom['_id'];
|
||||
ready: boolean;
|
||||
dom?: Node;
|
||||
streamActive?: boolean;
|
||||
unreadSince: Date | undefined;
|
||||
lastSeen: Date;
|
||||
unreadFirstId?: string;
|
||||
stream?: {
|
||||
stop: () => void;
|
||||
};
|
||||
};
|
||||
|
||||
const openedRooms: Record<string, OpenedRoom> = {};
|
||||
|
||||
function close(typeName: string) {
|
||||
if (openedRooms[typeName]) {
|
||||
openedRooms[typeName].stream?.stop();
|
||||
|
||||
openedRooms[typeName].ready = false;
|
||||
|
||||
delete openedRooms[typeName].dom;
|
||||
|
||||
const { rid } = openedRooms[typeName];
|
||||
delete openedRooms[typeName];
|
||||
|
||||
if (rid) {
|
||||
RoomManager.close(rid);
|
||||
return RoomHistoryManager.close(rid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function closeOlderRooms() {
|
||||
if (Object.keys(openedRooms).length <= maxRoomsOpen) {
|
||||
return;
|
||||
}
|
||||
const roomsToClose = Object.values(openedRooms)
|
||||
.sort((a, b) => b.lastSeen.getTime() - a.lastSeen.getTime())
|
||||
.slice(maxRoomsOpen);
|
||||
return Array.from(roomsToClose).map((roomToClose) => close(roomToClose.typeName));
|
||||
}
|
||||
|
||||
async function closeAllRooms() {
|
||||
for await (const openedRoom of Object.values(openedRooms)) {
|
||||
await close(openedRoom.typeName);
|
||||
}
|
||||
}
|
||||
|
||||
function listenRoomPropsByRid<T extends ListenRoomPropsByRidProps>(
|
||||
rid: IRoom['_id'],
|
||||
prop: T,
|
||||
): {
|
||||
subscribe: (cb: () => void) => () => void;
|
||||
getSnapshotValue: () => OpenedRoom[T];
|
||||
} {
|
||||
return {
|
||||
subscribe: (cb: () => void) => {
|
||||
return listener.on(`${rid}/${prop}`, cb);
|
||||
},
|
||||
getSnapshotValue: (): OpenedRoom[T] => {
|
||||
return getOpenedRoomByRid(rid)?.[prop] as OpenedRoom[T];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setPropertyByRid<T extends ListenRoomPropsByRidProps>(room: OpenedRoom, prop: T, value: OpenedRoom[T]): OpenedRoom[T] | undefined;
|
||||
function setPropertyByRid<T extends ListenRoomPropsByRidProps>(rid: IRoom['_id'], prop: T, value: OpenedRoom[T]): OpenedRoom[T] | undefined;
|
||||
function setPropertyByRid<T extends ListenRoomPropsByRidProps>(
|
||||
ridOrRoom: IRoom['_id'] | OpenedRoom,
|
||||
prop: T,
|
||||
value: OpenedRoom[T],
|
||||
): OpenedRoom[T] | undefined {
|
||||
const room = typeof ridOrRoom === 'string' ? getOpenedRoomByRid(ridOrRoom) : ridOrRoom;
|
||||
const rid = typeof ridOrRoom === 'string' ? ridOrRoom : room?.rid;
|
||||
if (!room) {
|
||||
return;
|
||||
}
|
||||
room[prop] = value;
|
||||
listener.emit(`${rid}/${prop}`);
|
||||
}
|
||||
|
||||
function getOpenedRoomByRid(rid: IRoom['_id']) {
|
||||
return Object.keys(openedRooms)
|
||||
.map((typeName) => openedRooms[typeName])
|
||||
.find((openedRoom) => openedRoom.rid === rid);
|
||||
}
|
||||
|
||||
function createDeleteQuery({
|
||||
excludePinned,
|
||||
ignoreDiscussion,
|
||||
rid,
|
||||
ts,
|
||||
users,
|
||||
ids,
|
||||
}: {
|
||||
rid: IMessage['rid'];
|
||||
excludePinned: boolean;
|
||||
ignoreDiscussion: boolean;
|
||||
ts: Record<string, Date>;
|
||||
users: string[];
|
||||
ids?: string[];
|
||||
}) {
|
||||
const query: Filter<IMessage> = { rid };
|
||||
|
||||
if (ids) {
|
||||
query._id = { $in: ids };
|
||||
} else {
|
||||
query.ts = ts;
|
||||
}
|
||||
if (excludePinned) {
|
||||
query.pinned = { $ne: true };
|
||||
}
|
||||
if (ignoreDiscussion) {
|
||||
query.drid = { $exists: false };
|
||||
}
|
||||
if (users?.length) {
|
||||
query['u.username'] = { $in: users };
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
const openRoom = (typeName: string, record: OpenedRoom) => {
|
||||
if (record.ready === true && record.streamActive === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (record.streamActive === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
const type = typeName.slice(0, 1);
|
||||
const name = typeName.slice(1);
|
||||
|
||||
const room = roomCoordinator.getRoomDirectives(type).findRoom(name);
|
||||
|
||||
if (!room) {
|
||||
return;
|
||||
}
|
||||
|
||||
const streams: ReturnType<typeof sdk.stream>[] = [];
|
||||
|
||||
streams.push(
|
||||
...[
|
||||
sdk.stream('room-messages', [record.rid], async (msg) => {
|
||||
// Should not send message to room if room has not loaded all the current messages
|
||||
// if (RoomHistoryManager.hasMoreNext(record.rid) !== false) {
|
||||
// return;
|
||||
// }
|
||||
// Do not load command messages into channel
|
||||
if (msg.t !== 'command') {
|
||||
const subscription = Subscriptions.state.find(({ rid }) => rid === record.rid);
|
||||
const isNew = !Messages.state.find((record) => record._id === msg._id && record.temp !== true);
|
||||
|
||||
// Measure and log message receive delay for messages
|
||||
if (msg.ts) {
|
||||
const receiveDelay = Date.now() - new Date(msg.ts).getTime();
|
||||
|
||||
// Log warning if delay is significant (>2 seconds)
|
||||
if (receiveDelay > 2000) {
|
||||
console.warn(`[Message Delivery] High delay detected: ${receiveDelay}ms. Possible network or backend issue.`);
|
||||
}
|
||||
}
|
||||
|
||||
await upsertMessage({ msg, subscription });
|
||||
if (isNew) {
|
||||
await clientCallbacks.run('streamNewMessage', msg);
|
||||
}
|
||||
}
|
||||
|
||||
await clientCallbacks.run('streamMessage', { ...msg, name: room.name || '' });
|
||||
|
||||
fireGlobalEvent('new-message', {
|
||||
...msg,
|
||||
name: room.name || '',
|
||||
room: {
|
||||
type,
|
||||
name,
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
// when we receive a messages imported event we just clear the room history and fetch it again
|
||||
sdk.stream('notify-room', [`${record.rid}/messagesImported`], async () => {
|
||||
await RoomHistoryManager.clear(record.rid);
|
||||
await RoomHistoryManager.getMore(record.rid);
|
||||
}),
|
||||
sdk.stream('notify-room', [`${record.rid}/deleteMessage`], (msg) => {
|
||||
Messages.state.delete(msg._id);
|
||||
|
||||
// remove thread refenrece from deleted message
|
||||
Messages.state.update(
|
||||
(record) => record.tmid === msg._id,
|
||||
({ tmid: _, ...record }) => record,
|
||||
);
|
||||
}),
|
||||
sdk.stream('notify-room', [`${record.rid}/deleteMessageBulk`], async (params) => {
|
||||
const query = createDeleteQuery(params);
|
||||
const predicate = createPredicateFromFilter(query);
|
||||
|
||||
if (params.filesOnly) {
|
||||
return Messages.state.update(predicate, (record) => modifyMessageOnFilesDelete(record, params.replaceFileAttachmentsWith));
|
||||
}
|
||||
|
||||
if (params.showDeletedStatus) {
|
||||
return Messages.state.update(predicate, (record) => ({
|
||||
...record,
|
||||
t: 'rm',
|
||||
msg: '',
|
||||
urls: [],
|
||||
mentions: [],
|
||||
attachments: [],
|
||||
reactions: {},
|
||||
}));
|
||||
}
|
||||
|
||||
return Messages.state.remove(predicate);
|
||||
}),
|
||||
|
||||
sdk.stream('notify-room', [`${record.rid}/messagesRead`], ({ tmid, until }) => {
|
||||
if (tmid) {
|
||||
Messages.state.update(
|
||||
(record) => record.tmid === tmid && record.unread === true,
|
||||
({ unread: _, ...record }) => record,
|
||||
);
|
||||
return;
|
||||
}
|
||||
Messages.state.update(
|
||||
(r) =>
|
||||
r.rid === record.rid && r.unread === true && r.ts.getTime() < until.getTime() && (r.tmid === undefined || r.tshow === true),
|
||||
({ unread: _, ...r }) => r,
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
const [streamRoomMessages] = streams;
|
||||
|
||||
void streamRoomMessages.ready().then(() => {
|
||||
setPropertyByRid(record.rid, 'streamActive', true);
|
||||
});
|
||||
|
||||
record.stream = {
|
||||
stop: () => {
|
||||
streams.forEach((stream) => stream.stop());
|
||||
},
|
||||
};
|
||||
|
||||
record.ready = true;
|
||||
};
|
||||
|
||||
function open({ typeName, rid }: { typeName: string; rid: IRoom['_id'] }) {
|
||||
if (!openedRooms[typeName]) {
|
||||
openedRooms[typeName] = {
|
||||
typeName,
|
||||
rid,
|
||||
ready: false,
|
||||
unreadSince: undefined,
|
||||
lastSeen: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
openedRooms[typeName].lastSeen = new Date();
|
||||
|
||||
if (openedRooms[typeName].ready) {
|
||||
closeOlderRooms();
|
||||
}
|
||||
|
||||
openRoom(typeName, openedRooms[typeName]);
|
||||
}
|
||||
|
||||
let openedRoom: string | undefined = undefined;
|
||||
|
||||
export const LegacyRoomManager = {
|
||||
get openedRoom() {
|
||||
return openedRoom;
|
||||
},
|
||||
|
||||
set openedRoom(rid) {
|
||||
openedRoom = rid;
|
||||
},
|
||||
|
||||
get openedRooms() {
|
||||
return openedRooms;
|
||||
},
|
||||
listenRoomPropsByRid,
|
||||
setPropertyByRid,
|
||||
getOpenedRoomByRid,
|
||||
|
||||
close,
|
||||
|
||||
closeAllRooms,
|
||||
|
||||
open,
|
||||
};
|
||||
33
apps/meteor/client/lib/MessageAction.ts
Normal file
33
apps/meteor/client/lib/MessageAction.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { Keys as IconName } from '@rocket.chat/icons';
|
||||
import type { TranslationKey } from '@rocket.chat/ui-contexts';
|
||||
|
||||
type MessageActionGroup = 'menu';
|
||||
|
||||
export type MessageActionContext =
|
||||
| 'message'
|
||||
| 'threads'
|
||||
| 'message-mobile'
|
||||
| 'pinned'
|
||||
| 'direct'
|
||||
| 'starred'
|
||||
| 'mentions'
|
||||
| 'federated'
|
||||
| 'videoconf'
|
||||
| 'search'
|
||||
| 'videoconf-threads';
|
||||
|
||||
type MessageActionType = 'communication' | 'interaction' | 'duplication' | 'apps' | 'management';
|
||||
|
||||
export type MessageActionConfig = {
|
||||
id: string;
|
||||
icon: IconName;
|
||||
variant?: 'danger' | 'success' | 'warning';
|
||||
label: TranslationKey;
|
||||
tooltip?: string;
|
||||
order: number;
|
||||
group: MessageActionGroup;
|
||||
context?: MessageActionContext[];
|
||||
action: (e: Pick<Event, 'preventDefault' | 'stopPropagation' | 'currentTarget'> | undefined) => any;
|
||||
type?: MessageActionType;
|
||||
disabled?: boolean;
|
||||
};
|
||||
72
apps/meteor/client/lib/RestApiClient.ts
Normal file
72
apps/meteor/client/lib/RestApiClient.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
import { RestClient } from '@rocket.chat/api-client';
|
||||
|
||||
import { invokeTwoFactorModal } from './2fa/process2faReturn';
|
||||
import { baseURI } from './baseURI';
|
||||
import { clearStoredCredentials } from './sdk/ddpSdk';
|
||||
import { STORAGE_KEYS, getStoredItem } from './sdk/storage';
|
||||
|
||||
class RestApiClient extends RestClient {
|
||||
override getCredentials():
|
||||
| {
|
||||
'X-User-Id': string;
|
||||
'X-Auth-Token': string;
|
||||
}
|
||||
| undefined {
|
||||
const [uid, token] = [getStoredItem(STORAGE_KEYS.USER_ID), getStoredItem(STORAGE_KEYS.LOGIN_TOKEN)];
|
||||
|
||||
if (!uid || !token) {
|
||||
return;
|
||||
}
|
||||
return {
|
||||
'X-User-Id': uid,
|
||||
'X-Auth-Token': token,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const APIClient = new RestApiClient({
|
||||
baseUrl: baseURI.replace(/\/$/, ''),
|
||||
});
|
||||
|
||||
APIClient.handleTwoFactorChallenge(invokeTwoFactorModal);
|
||||
|
||||
/**
|
||||
* The original rest api code throws the Response object, which is very useful
|
||||
* for the client sometimes, if the developer wants to access more information about the error
|
||||
* unfortunately/fortunately Rocket.Chat expects an error object (from Response.json()
|
||||
* This middleware will throw the error object instead.
|
||||
* */
|
||||
|
||||
/**
|
||||
* A 401 means the request reached an authRequired route without a session the server recognizes.
|
||||
* That is only evidence of an expired session when the request was an action the user just took:
|
||||
* boot-time reads (OmnichannelProvider's livechat/config/routing, custom-sounds.list) fire before
|
||||
* the session is established and answer 401 with the same message, and clearing on those logs out
|
||||
* a session that is coming up. Restricting the wipe to mutations mirrors the DDP path this replaced
|
||||
* — ddpOverREST only clears credentials for method calls, never for background fetches — so a
|
||||
* session that dies while idle is noticed on the user's next write, exactly as before.
|
||||
*/
|
||||
const isMutation = (method: string): boolean => method === 'POST' || method === 'PUT' || method === 'DELETE';
|
||||
|
||||
APIClient.use(async (request, next) => {
|
||||
const [, method] = request;
|
||||
const tokenAtSend = getStoredItem(STORAGE_KEYS.LOGIN_TOKEN);
|
||||
|
||||
try {
|
||||
return await next(...request);
|
||||
} catch (error) {
|
||||
if (error instanceof Response) {
|
||||
// Never 403 (authenticated but lacking permission) — that must not log the user out.
|
||||
// The token comparison keeps a request that was in flight across a re-login from
|
||||
// evicting the session it does not belong to.
|
||||
if (error.status === 401 && isMutation(method) && tokenAtSend && tokenAtSend === getStoredItem(STORAGE_KEYS.LOGIN_TOKEN)) {
|
||||
clearStoredCredentials();
|
||||
}
|
||||
const e = await error.json();
|
||||
throw e;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
471
apps/meteor/client/lib/RoomHistoryManager.ts
Normal file
471
apps/meteor/client/lib/RoomHistoryManager.ts
Normal file
@@ -0,0 +1,471 @@
|
||||
import type { IMessage, IRoom, ISubscription } from '@rocket.chat/core-typings';
|
||||
import { Emitter } from '@rocket.chat/emitter';
|
||||
import { differenceInMilliseconds } from 'date-fns';
|
||||
import { useCallback, useSyncExternalStore } from 'react';
|
||||
|
||||
import { getUserPreference } from './getUserPreference';
|
||||
import { dispatchToastMessage } from './toast';
|
||||
import { Messages, Subscriptions } from '../stores';
|
||||
import { sdk } from './SDKClient';
|
||||
import { onClientMessageReceived } from './onClientMessageReceived';
|
||||
import { getUserId } from './user';
|
||||
import { getConfig } from './utils/getConfig';
|
||||
import { mapMessageFromApi } from './utils/mapMessageFromApi';
|
||||
|
||||
const processMessage = async (msg: IMessage & { ignored?: boolean }, { subscription }: { subscription?: ISubscription }) => {
|
||||
const userId = msg.u?._id;
|
||||
|
||||
if (subscription?.ignored?.includes(userId)) {
|
||||
msg.ignored = true;
|
||||
}
|
||||
|
||||
if (msg.t === 'e2e') {
|
||||
msg.e2e = 'pending';
|
||||
}
|
||||
|
||||
return (await onClientMessageReceived(msg)) || msg;
|
||||
};
|
||||
|
||||
export async function upsertMessage({ msg, subscription }: { msg: IMessage & { ignored?: boolean }; subscription?: ISubscription }) {
|
||||
Messages.state.store(await processMessage(msg, { subscription }));
|
||||
}
|
||||
|
||||
export async function upsertMessageBulk({
|
||||
msgs,
|
||||
subscription,
|
||||
}: {
|
||||
msgs: (IMessage & { ignored?: boolean })[];
|
||||
subscription?: ISubscription;
|
||||
}) {
|
||||
const processedMsgs = await Promise.all(msgs.map(async (msg) => processMessage(msg, { subscription })));
|
||||
Messages.state.storeMany(processedMsgs);
|
||||
}
|
||||
|
||||
const defaultLimit = parseInt(getConfig('roomListLimit') ?? '50') || 50;
|
||||
|
||||
export type RoomHistoryState = {
|
||||
hasMore: boolean;
|
||||
hasMoreNext: boolean;
|
||||
isLoading: boolean;
|
||||
unreadNotLoaded: number;
|
||||
firstUnread: IMessage | undefined;
|
||||
loaded: number | undefined;
|
||||
cursorPrevious?: string | null;
|
||||
cursorNext?: string | null;
|
||||
scroll?: {
|
||||
scrollHeight: number;
|
||||
scrollTop: number;
|
||||
};
|
||||
};
|
||||
|
||||
const roomStateEvent = (rid: IRoom['_id']) => `state:${rid}` as const;
|
||||
|
||||
class RoomHistoryManagerClass extends Emitter {
|
||||
private lastRequest?: Date;
|
||||
|
||||
private histories: Record<IRoom['_id'], RoomHistoryState> = {};
|
||||
|
||||
// Bumped by `clear`/`close`. In-flight requests capture it at entry and discard their
|
||||
// response if it changed, so a page from a dead window can't touch the rebuilt one.
|
||||
private generations: Record<IRoom['_id'], number> = {};
|
||||
|
||||
private requestsList: string[] = [];
|
||||
|
||||
private generation(rid: IRoom['_id']): number {
|
||||
return this.generations[rid] ?? 0;
|
||||
}
|
||||
|
||||
public getRoom(rid: IRoom['_id']): RoomHistoryState {
|
||||
if (!this.histories[rid]) {
|
||||
this.histories[rid] = {
|
||||
hasMore: true,
|
||||
hasMoreNext: false,
|
||||
isLoading: false,
|
||||
unreadNotLoaded: 0,
|
||||
firstUnread: undefined,
|
||||
loaded: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return this.histories[rid];
|
||||
}
|
||||
|
||||
public updateRoom(rid: IRoom['_id'], patch: Partial<RoomHistoryState>): void {
|
||||
const room = this.getRoom(rid);
|
||||
Object.assign(room, patch);
|
||||
this.emit(roomStateEvent(rid), room);
|
||||
}
|
||||
|
||||
public subscribeToRoom(rid: IRoom['_id'], cb: (state: RoomHistoryState) => void): () => void {
|
||||
return this.on(roomStateEvent(rid), cb);
|
||||
}
|
||||
|
||||
private async queue(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const requestId = crypto.randomUUID();
|
||||
const done = () => {
|
||||
this.lastRequest = new Date();
|
||||
resolve();
|
||||
};
|
||||
if (this.requestsList.length === 0) {
|
||||
return this.run(done);
|
||||
}
|
||||
this.requestsList.push(requestId);
|
||||
this.once(requestId, done);
|
||||
});
|
||||
}
|
||||
|
||||
private run(fn: () => void) {
|
||||
const difference = this.lastRequest ? differenceInMilliseconds(new Date(), this.lastRequest) : Infinity;
|
||||
// Original cooldown was 500ms which forced ~330ms wait on the second getMore call when a
|
||||
// user opens a room. Pagination throughput here is bounded by the loadHistory server
|
||||
// method itself, so a smaller client-side spacing is enough to avoid hammering.
|
||||
const minSpacingMs = 100;
|
||||
if (difference > minSpacingMs) {
|
||||
return fn();
|
||||
}
|
||||
return setTimeout(fn, minSpacingMs - difference);
|
||||
}
|
||||
|
||||
public isLoaded(rid: IRoom['_id']) {
|
||||
const room = this.getRoom(rid);
|
||||
return room.loaded !== undefined;
|
||||
}
|
||||
|
||||
private unqueue() {
|
||||
const requestId = this.requestsList.pop();
|
||||
if (!requestId) {
|
||||
return;
|
||||
}
|
||||
this.run(() => this.emit(requestId));
|
||||
}
|
||||
|
||||
public async getMore(rid: IRoom['_id'], { limit = defaultLimit }: { limit?: number } = {}): Promise<void> {
|
||||
const room = this.getRoom(rid);
|
||||
|
||||
if (room.hasMore !== true) {
|
||||
return;
|
||||
}
|
||||
|
||||
const generation = this.generation(rid);
|
||||
|
||||
try {
|
||||
this.updateRoom(rid, { isLoading: true });
|
||||
|
||||
await this.queue();
|
||||
|
||||
// A `clear` may have run while queued (jump to message, jump to recent): the window this
|
||||
// cursor belonged to is gone, and a rebuild may already have installed a different one.
|
||||
if (generation !== this.generation(rid)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let ls = undefined;
|
||||
|
||||
const subscription = Subscriptions.state.find((record) => record.rid === rid);
|
||||
if (subscription) {
|
||||
({ ls } = subscription);
|
||||
}
|
||||
|
||||
const showThreadsInMainChannel = getUserPreference(getUserId(), 'showThreadsInMainChannel', false);
|
||||
|
||||
const { cursorPrevious: previous } = room;
|
||||
|
||||
const result = await sdk.rest.get('/v1/rooms.history', {
|
||||
roomId: rid,
|
||||
...(previous && { previous }),
|
||||
...(ls && { lastSeen: new Date(ls).toISOString() }),
|
||||
count: limit,
|
||||
showThreadMessages: showThreadsInMainChannel,
|
||||
});
|
||||
|
||||
this.unqueue();
|
||||
|
||||
if (generation !== this.generation(rid)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const messages = result.messages.map((msg) => mapMessageFromApi(msg));
|
||||
this.updateRoom(rid, {
|
||||
unreadNotLoaded: result.unreadNotLoaded ?? 0,
|
||||
firstUnread: result.firstUnread && mapMessageFromApi(result.firstUnread),
|
||||
cursorPrevious: result.cursor.previous,
|
||||
hasMore: result.cursor.previous !== null,
|
||||
});
|
||||
|
||||
const wrapper = document.querySelector<HTMLElement>('.messages-box .wrapper [data-overlayscrollbars-viewport]');
|
||||
if (wrapper) {
|
||||
room.scroll = {
|
||||
scrollHeight: wrapper.scrollHeight,
|
||||
scrollTop: wrapper.scrollTop,
|
||||
};
|
||||
}
|
||||
|
||||
await upsertMessageBulk({
|
||||
msgs: messages.filter((msg) => msg.t !== 'command'),
|
||||
subscription,
|
||||
});
|
||||
|
||||
if (generation !== this.generation(rid)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.emit('loaded-messages');
|
||||
|
||||
if (!room.loaded) {
|
||||
room.loaded = 0;
|
||||
}
|
||||
|
||||
const visibleMessages = messages.filter((msg) => msg.t !== 'command' && (!msg.tmid || showThreadsInMainChannel || msg.tshow));
|
||||
|
||||
room.loaded += visibleMessages.length;
|
||||
|
||||
// `count` bounds raw messages, so a whole page can render as nothing and stall the scroll.
|
||||
if (room.hasMore && visibleMessages.length === 0) {
|
||||
return this.getMore(rid);
|
||||
}
|
||||
|
||||
this.emit('loaded-messages');
|
||||
} finally {
|
||||
// When stale, `clear` already reset the flag and the rebuild owns it now.
|
||||
if (generation === this.generation(rid)) {
|
||||
this.updateRoom(rid, { isLoading: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public restoreScroll(rid: IRoom['_id']) {
|
||||
const room = this.getRoom(rid);
|
||||
const wrapper = document.querySelector('.messages-box .wrapper [data-overlayscrollbars-viewport]');
|
||||
|
||||
if (room.scroll === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!wrapper) {
|
||||
return;
|
||||
}
|
||||
|
||||
const heightDiff = wrapper.scrollHeight - (room.scroll.scrollHeight ?? NaN);
|
||||
wrapper.scrollTop = room.scroll.scrollTop + heightDiff;
|
||||
room.scroll = undefined;
|
||||
}
|
||||
|
||||
public async getMoreNext(rid: IRoom['_id']) {
|
||||
const room = this.getRoom(rid);
|
||||
if (room.hasMoreNext !== true) {
|
||||
return;
|
||||
}
|
||||
|
||||
const generation = this.generation(rid);
|
||||
|
||||
try {
|
||||
this.updateRoom(rid, { isLoading: true });
|
||||
|
||||
await this.queue();
|
||||
|
||||
// A `clear` may have run while queued (jump to message, jump to recent): the window this
|
||||
// cursor belonged to is gone, and a rebuild may already have installed a different one.
|
||||
if (generation !== this.generation(rid)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { cursorNext: next } = room;
|
||||
if (!next) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subscription = Subscriptions.state.find((record) => record.rid === rid);
|
||||
const showThreadsInMainChannel = getUserPreference(getUserId(), 'showThreadsInMainChannel', false);
|
||||
|
||||
const result = await sdk.rest.get('/v1/rooms.history', {
|
||||
roomId: rid,
|
||||
next,
|
||||
count: defaultLimit,
|
||||
showThreadMessages: showThreadsInMainChannel,
|
||||
});
|
||||
|
||||
// Covers a `clear` that landed while the request was on the wire.
|
||||
if (generation !== this.generation(rid)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const messages = result.messages.map((msg) => mapMessageFromApi(msg));
|
||||
|
||||
await upsertMessageBulk({
|
||||
msgs: messages.filter((msg) => msg.t !== 'command'),
|
||||
subscription,
|
||||
});
|
||||
|
||||
if (generation !== this.generation(rid)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.emit('loaded-messages');
|
||||
|
||||
this.updateRoom(rid, {
|
||||
cursorNext: result.cursor.next,
|
||||
hasMoreNext: result.cursor.next !== null,
|
||||
});
|
||||
if (!room.loaded) {
|
||||
room.loaded = 0;
|
||||
}
|
||||
|
||||
room.loaded += messages.length;
|
||||
} finally {
|
||||
if (generation === this.generation(rid)) {
|
||||
this.updateRoom(rid, { isLoading: false });
|
||||
}
|
||||
this.unqueue();
|
||||
}
|
||||
}
|
||||
|
||||
public hasMore(rid: IRoom['_id']) {
|
||||
return this.getRoom(rid).hasMore;
|
||||
}
|
||||
|
||||
public hasMoreNext(rid: IRoom['_id']) {
|
||||
return this.getRoom(rid).hasMoreNext;
|
||||
}
|
||||
|
||||
public getMoreIfIsEmpty(rid: IRoom['_id']) {
|
||||
const room = this.getRoom(rid);
|
||||
|
||||
if (room.loaded === undefined) {
|
||||
return this.getMore(rid);
|
||||
}
|
||||
}
|
||||
|
||||
public isLoading(rid: IRoom['_id']) {
|
||||
return this.getRoom(rid).isLoading;
|
||||
}
|
||||
|
||||
public close(rid: IRoom['_id']) {
|
||||
this.generations[rid] = this.generation(rid) + 1;
|
||||
Messages.state.remove((record) => record.rid === rid);
|
||||
delete this.histories[rid];
|
||||
}
|
||||
|
||||
public clear(rid: IRoom['_id']) {
|
||||
this.generations[rid] = this.generation(rid) + 1;
|
||||
const room = this.getRoom(rid);
|
||||
Messages.state.remove((record) => record.rid === rid);
|
||||
room.loaded = undefined;
|
||||
this.updateRoom(rid, {
|
||||
isLoading: false,
|
||||
hasMore: true,
|
||||
hasMoreNext: false,
|
||||
cursorPrevious: undefined,
|
||||
cursorNext: undefined,
|
||||
// The snapshot describes the DOM of the window being discarded; a stale restoreScroll
|
||||
// resuming after this clear must no-op instead of applying it to the rebuilt window.
|
||||
scroll: undefined,
|
||||
});
|
||||
this.emit('room-cleared', rid);
|
||||
}
|
||||
|
||||
public async getSurroundingMessages(message?: Pick<IMessage, '_id' | 'rid'> & { ts?: Date }) {
|
||||
return this.loadSurroundingMessages(message, true);
|
||||
}
|
||||
|
||||
public async getSurroundingChannelMessages(message?: Pick<IMessage, '_id' | 'rid'> & { ts?: Date }) {
|
||||
return this.loadSurroundingMessages(message, false);
|
||||
}
|
||||
|
||||
private async loadSurroundingMessages(message: (Pick<IMessage, '_id' | 'rid'> & { ts?: Date }) | undefined, showThreadMessages: boolean) {
|
||||
if (!message?.rid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const messageAlreadyLoaded = Messages.state.some((record) => record._id === message._id && record._hidden !== true);
|
||||
|
||||
if (messageAlreadyLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
const room = this.getRoom(message.rid);
|
||||
this.updateRoom(message.rid, { isLoading: true });
|
||||
|
||||
const subscription = Subscriptions.state.find((record) => record.rid === message.rid);
|
||||
|
||||
let generation = this.generation(message.rid);
|
||||
|
||||
try {
|
||||
const result = await sdk.rest.get('/v1/rooms.history', {
|
||||
roomId: message.rid,
|
||||
aroundId: message._id,
|
||||
count: defaultLimit,
|
||||
showThreadMessages,
|
||||
});
|
||||
|
||||
// A `clear`/`close` that landed while the fetch was on the wire superseded this jump;
|
||||
// clearing here would wipe the newer window and rebuild a stale one over it.
|
||||
if (generation !== this.generation(message.rid)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Rebuilds the window around the target, so the store does not grow monotonically here.
|
||||
this.clear(message.rid);
|
||||
|
||||
// This rebuild owns the window created by its own `clear`; a later `clear`
|
||||
// (jump to recent, messagesImported) makes the rest of this flow stale.
|
||||
generation = this.generation(message.rid);
|
||||
|
||||
// `clear` drops the cursors and clears `isLoading`, so restore the previous side before the
|
||||
// upsert yields: a `getMore` landing in that window has no cursor and would page from the
|
||||
// newest end instead.
|
||||
this.updateRoom(message.rid, {
|
||||
isLoading: true,
|
||||
cursorPrevious: result.cursor.previous,
|
||||
hasMore: result.cursor.previous !== null,
|
||||
});
|
||||
|
||||
const messages = result.messages.map((msg) => mapMessageFromApi(msg));
|
||||
|
||||
await upsertMessageBulk({ msgs: messages.filter((msg) => msg.t !== 'command'), subscription });
|
||||
|
||||
if (generation !== this.generation(message.rid)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The next side only lands after the upsert: `hasMoreNext` arms jump-to-recent
|
||||
// (useHasNewMessages), which would clear the store while the upsert is still pending.
|
||||
this.updateRoom(message.rid, {
|
||||
cursorNext: result.cursor.next,
|
||||
hasMoreNext: result.cursor.next !== null,
|
||||
});
|
||||
|
||||
this.emit('loaded-messages');
|
||||
|
||||
if (!room.loaded) {
|
||||
room.loaded = 0;
|
||||
}
|
||||
room.loaded += messages.length;
|
||||
} catch (error) {
|
||||
if (generation !== this.generation(message.rid)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The target may have been deleted since the link was created; no window to build then.
|
||||
if (!(error instanceof Response && error.status === 404)) {
|
||||
dispatchToastMessage({ type: 'error', message: error });
|
||||
}
|
||||
|
||||
if (!this.isLoaded(message.rid)) {
|
||||
await this.getMore(message.rid);
|
||||
}
|
||||
} finally {
|
||||
if (generation === this.generation(message.rid)) {
|
||||
this.updateRoom(message.rid, { isLoading: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const RoomHistoryManager = new RoomHistoryManagerClass();
|
||||
|
||||
export const useRoomHistoryState = <T>(rid: IRoom['_id'], selector: (state: RoomHistoryState) => T): T =>
|
||||
useSyncExternalStore(
|
||||
useCallback((onStoreChange) => RoomHistoryManager.subscribeToRoom(rid, onStoreChange), [rid]),
|
||||
() => selector(RoomHistoryManager.getRoom(rid)),
|
||||
);
|
||||
171
apps/meteor/client/lib/RoomManager.ts
Normal file
171
apps/meteor/client/lib/RoomManager.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
import { Emitter } from '@rocket.chat/emitter';
|
||||
import { useMemo, useSyncExternalStore } from 'react';
|
||||
|
||||
import { LegacyRoomManager } from './LegacyRoomManager';
|
||||
import { RoomHistoryManager } from './RoomHistoryManager';
|
||||
import { getConfig } from './utils/getConfig';
|
||||
|
||||
const debug = !!(getConfig('debug') || getConfig('debug-RoomStore'));
|
||||
|
||||
type CollapsibleKey = `collapsibleToggled-${string}`;
|
||||
|
||||
export const getCollapsibleEventKey = (key: string): CollapsibleKey => `collapsibleToggled-${key}`;
|
||||
|
||||
class RoomStore extends Emitter<{
|
||||
changed: undefined;
|
||||
[key: CollapsibleKey]: undefined;
|
||||
}> {
|
||||
lastTime?: Date;
|
||||
|
||||
scroll?: number;
|
||||
|
||||
lm?: Date;
|
||||
|
||||
atBottom = true;
|
||||
|
||||
private readonly toggledCollapsibles = new Set<string>();
|
||||
|
||||
constructor(readonly rid: string) {
|
||||
super();
|
||||
|
||||
debug && this.on('changed', () => console.log(`RoomStore ${this.rid} changed`, this));
|
||||
}
|
||||
|
||||
update({ scroll, lastTime, atBottom }: { scroll?: number; lastTime?: Date; atBottom?: boolean }): void {
|
||||
if (scroll !== undefined) {
|
||||
this.scroll = scroll;
|
||||
}
|
||||
if (lastTime !== undefined) {
|
||||
this.lastTime = lastTime;
|
||||
}
|
||||
|
||||
if (atBottom !== undefined) {
|
||||
this.atBottom = atBottom;
|
||||
}
|
||||
if (scroll || lastTime) {
|
||||
this.emit('changed');
|
||||
}
|
||||
}
|
||||
|
||||
toggleCollapsible(key: string): void {
|
||||
if (this.toggledCollapsibles.has(key)) {
|
||||
this.toggledCollapsibles.delete(key);
|
||||
this.emit(getCollapsibleEventKey(key));
|
||||
return;
|
||||
}
|
||||
|
||||
this.toggledCollapsibles.add(key);
|
||||
this.emit(getCollapsibleEventKey(key));
|
||||
}
|
||||
|
||||
isCollapsibleToggled(key: string): boolean {
|
||||
return this.toggledCollapsibles.has(key);
|
||||
}
|
||||
}
|
||||
|
||||
const debugRoomManager = !!(getConfig('debug') || getConfig('debug-RoomManager'));
|
||||
export const RoomManager = new (class RoomManager extends Emitter<{
|
||||
changed: IRoom['_id'] | undefined;
|
||||
opened: IRoom['_id'];
|
||||
closed: IRoom['_id'];
|
||||
back: IRoom['_id'];
|
||||
removed: IRoom['_id'];
|
||||
}> {
|
||||
private rid: IRoom['_id'] | undefined;
|
||||
|
||||
public lastRid: IRoom['_id'] | undefined;
|
||||
|
||||
private rooms: Map<IRoom['_id'], RoomStore> = new Map();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
debugRoomManager &&
|
||||
this.on('opened', (rid) => {
|
||||
console.log('room opened ->', rid);
|
||||
});
|
||||
|
||||
debugRoomManager &&
|
||||
this.on('back', (rid) => {
|
||||
console.log('room moved to back ->', rid);
|
||||
});
|
||||
|
||||
debugRoomManager &&
|
||||
this.on('closed', (rid) => {
|
||||
console.log('room close ->', rid);
|
||||
});
|
||||
}
|
||||
|
||||
get lastOpened(): IRoom['_id'] | undefined {
|
||||
return this.lastRid;
|
||||
}
|
||||
|
||||
get opened(): IRoom['_id'] | undefined {
|
||||
return this.rid;
|
||||
}
|
||||
|
||||
visitedRooms(): IRoom['_id'][] {
|
||||
return [...this.rooms.keys()];
|
||||
}
|
||||
|
||||
back(rid: IRoom['_id']): void {
|
||||
if (rid === this.rid) {
|
||||
this.lastRid = rid;
|
||||
this.rid = undefined;
|
||||
this.emit('back', rid);
|
||||
this.emit('changed', this.rid);
|
||||
}
|
||||
}
|
||||
|
||||
getMore(rid: IRoom['_id']): void {
|
||||
RoomHistoryManager.getMore(rid);
|
||||
}
|
||||
|
||||
close(rid: IRoom['_id']): void {
|
||||
if (this.rooms.has(rid)) {
|
||||
this.rooms.delete(rid);
|
||||
this.emit('closed', rid);
|
||||
}
|
||||
this.emit('changed', this.rid);
|
||||
}
|
||||
|
||||
open(rid: IRoom['_id']): void {
|
||||
if (rid === this.rid) {
|
||||
return;
|
||||
}
|
||||
this.back(rid);
|
||||
if (!this.rooms.has(rid)) {
|
||||
this.rooms.set(rid, new RoomStore(rid));
|
||||
}
|
||||
this.rid = rid;
|
||||
this.emit('opened', this.rid);
|
||||
this.emit('changed', this.rid);
|
||||
}
|
||||
|
||||
getStore(rid: IRoom['_id']): RoomStore | undefined {
|
||||
return this.rooms.get(rid);
|
||||
}
|
||||
})();
|
||||
|
||||
const subscribeOpenedRoom = [
|
||||
(callback: () => void): (() => void) => RoomManager.on('changed', callback),
|
||||
(): IRoom['_id'] | undefined => RoomManager.opened,
|
||||
] as const;
|
||||
|
||||
export const useOpenedRoom = (): IRoom['_id'] | undefined => useSyncExternalStore(...subscribeOpenedRoom);
|
||||
|
||||
export const useOpenedRoomUnreadSince = (): Date | undefined => {
|
||||
const rid = useOpenedRoom();
|
||||
|
||||
const { subscribe, getSnapshotValue } = useMemo(() => {
|
||||
if (!rid) {
|
||||
return {
|
||||
subscribe: () => () => void 0,
|
||||
getSnapshotValue: () => undefined,
|
||||
};
|
||||
}
|
||||
return LegacyRoomManager.listenRoomPropsByRid(rid, 'unreadSince');
|
||||
}, [rid]);
|
||||
|
||||
return useSyncExternalStore(subscribe, getSnapshotValue);
|
||||
};
|
||||
456
apps/meteor/client/lib/SDKClient.ts
Normal file
456
apps/meteor/client/lib/SDKClient.ts
Normal file
@@ -0,0 +1,456 @@
|
||||
import type { RestClientInterface } from '@rocket.chat/api-client';
|
||||
import type { SDK, ClientStream, StreamKeys, StreamNames, StreamerCallbackArgs, ServerMethods } from '@rocket.chat/ddp-client';
|
||||
import { Emitter } from '@rocket.chat/emitter';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { APIClient } from './RestApiClient';
|
||||
import { parseDDP } from './sdk/ddpProtocol';
|
||||
import { ensureConnectedAndAuthenticated, getDdpSdk } from './sdk/ddpSdk';
|
||||
import { isSdkTransportEnabled } from './sdk/sdkTransportEnabled';
|
||||
|
||||
declare module '@rocket.chat/ddp-client' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
interface SDK {
|
||||
stream<N extends StreamNames, K extends StreamKeys<N>>(
|
||||
streamName: N,
|
||||
args: [key: K, ...args: unknown[]],
|
||||
callback: (...args: StreamerCallbackArgs<N, K>) => void,
|
||||
): ReturnType<ClientStream['subscribe']>;
|
||||
onAnyStreamEvent<N extends StreamNames>(name: N, callback: (eventName: string, args: unknown[]) => void): { stop: () => void };
|
||||
call<T extends keyof ServerMethods>(method: T, ...args: Parameters<ServerMethods[T]>): Promise<ReturnType<ServerMethods[T]>>;
|
||||
}
|
||||
}
|
||||
|
||||
const sdkTransportEnabled = isSdkTransportEnabled();
|
||||
|
||||
const isChangedCollectionPayload = (
|
||||
msg: any,
|
||||
): msg is { msg: 'changed'; collection: string; fields: { eventName: string; args: unknown[] } } => {
|
||||
if (typeof msg !== 'object' && (msg !== null || msg !== undefined)) {
|
||||
return false;
|
||||
}
|
||||
if (msg.msg !== 'changed') {
|
||||
return false;
|
||||
}
|
||||
if (typeof msg.collection !== 'string') {
|
||||
return false;
|
||||
}
|
||||
if (typeof msg.fields !== 'object' && (msg.fields !== null || msg.fields !== undefined)) {
|
||||
return false;
|
||||
}
|
||||
if (typeof msg.fields.eventName !== 'string') {
|
||||
return false;
|
||||
}
|
||||
if (!Array.isArray(msg.fields.args)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
type EventMap<N extends StreamNames = StreamNames, K extends StreamKeys<N> = StreamKeys<N>> = {
|
||||
[key in `stream-${N}/${K}`]: StreamerCallbackArgs<N, K>;
|
||||
};
|
||||
|
||||
type StreamMapValue = {
|
||||
stop: () => void;
|
||||
onError: (cb: (...args: any[]) => void) => () => void;
|
||||
onChange: ReturnType<ClientStream['subscribe']>['onChange'];
|
||||
onStop: (cb: () => void) => () => void;
|
||||
ready: () => Promise<void>;
|
||||
isReady: boolean;
|
||||
unsubList: Set<() => void>;
|
||||
};
|
||||
|
||||
const createNewMeteorStream = (streamName: StreamNames, key: StreamKeys<StreamNames>, args: unknown[]): StreamMapValue => {
|
||||
const ee = new Emitter<{
|
||||
ready: [error: any] | [undefined, any];
|
||||
error: [error: any];
|
||||
stop: undefined;
|
||||
}>();
|
||||
const meta = {
|
||||
ready: false,
|
||||
};
|
||||
|
||||
const sub = Meteor.connection.subscribe(
|
||||
`stream-${streamName}`,
|
||||
key,
|
||||
{ useCollection: false, args },
|
||||
{
|
||||
onReady: (args: any) => {
|
||||
meta.ready = true;
|
||||
ee.emit('ready', [undefined, args]);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
ee.emit('ready', [err]);
|
||||
ee.emit('error', err);
|
||||
},
|
||||
onStop: () => {
|
||||
ee.emit('stop');
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const onChange: ReturnType<ClientStream['subscribe']>['onChange'] = (cb) => {
|
||||
if (meta.ready) {
|
||||
cb({
|
||||
msg: 'ready',
|
||||
|
||||
subs: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
ee.once('ready', ([error, result]) => {
|
||||
if (error) {
|
||||
cb({
|
||||
msg: 'nosub',
|
||||
|
||||
id: '',
|
||||
error,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
cb(result);
|
||||
});
|
||||
};
|
||||
|
||||
const ready = () => {
|
||||
if (meta.ready) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
ee.once('ready', ([err]) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
stop: sub.stop,
|
||||
onChange,
|
||||
ready,
|
||||
onError: (cb: (...args: any[]) => void) =>
|
||||
ee.once('error', (error) => {
|
||||
cb(error);
|
||||
}),
|
||||
|
||||
onStop: (cb: () => void) => ee.once('stop', cb),
|
||||
get isReady() {
|
||||
return meta.ready;
|
||||
},
|
||||
unsubList: new Set(),
|
||||
};
|
||||
};
|
||||
|
||||
const createNewDdpSdkStream = (
|
||||
streamProxy: Emitter<EventMap>,
|
||||
streamName: StreamNames,
|
||||
key: StreamKeys<StreamNames>,
|
||||
args: unknown[],
|
||||
): StreamMapValue => {
|
||||
const ee = new Emitter<{
|
||||
ready: [error: any] | [undefined, any];
|
||||
error: [error: any];
|
||||
stop: undefined;
|
||||
}>();
|
||||
const meta = { ready: false };
|
||||
|
||||
// Defer the actual `subscribe` until DDPSDK is authenticated. Without this,
|
||||
// stream subscriptions fired immediately after re-login (e.g. the
|
||||
// SubscriptionsCachedStore's `notify-user/<uid>/subscriptions-changed`
|
||||
// listener that re-arms via onLoggedIn) hit the SDK socket while it's
|
||||
// still anonymous — server rejects with `not-allowed`/`nosub`, the
|
||||
// stream's `ready` promise emits an error, and the cached store never
|
||||
// receives subsequent server events. The visible failure: an agent that
|
||||
// just took a livechat chat post-relogin sees the chat work but the
|
||||
// "Move to the queue" button never appears, because the new subscription
|
||||
// the server creates for that agent is never replicated to the client's
|
||||
// Subscriptions store, and pseudoRoom (= {...sub, ...room}) ends up with
|
||||
// no `u` for the canMoveQueue check.
|
||||
let subscription: ReturnType<ReturnType<typeof getDdpSdk>['client']['subscribe']> | undefined;
|
||||
let offCollection: (() => void) | undefined;
|
||||
let stopped = false;
|
||||
|
||||
void ensureConnectedAndAuthenticated()
|
||||
.catch(() => undefined)
|
||||
.then(() => {
|
||||
if (stopped) return;
|
||||
const sdk = getDdpSdk();
|
||||
subscription = sdk.client.subscribe(`stream-${streamName}`, key, { useCollection: false, args });
|
||||
|
||||
subscription
|
||||
.ready()
|
||||
.then(() => {
|
||||
if (stopped) return;
|
||||
meta.ready = true;
|
||||
ee.emit('ready', [undefined, { msg: 'ready', subs: [subscription!.id] }]);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (stopped) return;
|
||||
ee.emit('ready', [err]);
|
||||
ee.emit('error', err);
|
||||
});
|
||||
|
||||
offCollection = sdk.client.onCollection(`stream-${streamName}`, (data: any) => {
|
||||
if (data?.msg !== 'changed') return;
|
||||
if (data.collection !== `stream-${streamName}`) return;
|
||||
if (data.fields?.eventName !== key) return;
|
||||
streamProxy.emit(`stream-${streamName}/${key}`, data.fields.args);
|
||||
});
|
||||
});
|
||||
|
||||
const onChange: ReturnType<ClientStream['subscribe']>['onChange'] = (cb) => {
|
||||
if (meta.ready) {
|
||||
cb({ msg: 'ready', subs: [] });
|
||||
return;
|
||||
}
|
||||
ee.once('ready', ([error, result]) => {
|
||||
if (error) {
|
||||
cb({ msg: 'nosub', id: '', error });
|
||||
return;
|
||||
}
|
||||
cb(result);
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
stop: () => {
|
||||
// Mirror Meteor's subscription semantics: explicit stop() does not fire the
|
||||
// 'stop' event (onStop is reserved for server-initiated closures).
|
||||
// Emitting it here would recurse through the onStop handler that
|
||||
// createStreamManager registers, which itself iterates the unsubList.
|
||||
stopped = true;
|
||||
offCollection?.();
|
||||
subscription?.stop();
|
||||
},
|
||||
onChange,
|
||||
ready: () => {
|
||||
if (meta.ready) return Promise.resolve();
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
ee.once('ready', ([err]) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
},
|
||||
onError: (cb: (...args: any[]) => void) => ee.once('error', (error) => cb(error)),
|
||||
onStop: (cb: () => void) => ee.once('stop', cb),
|
||||
get isReady() {
|
||||
return meta.ready;
|
||||
},
|
||||
unsubList: new Set(),
|
||||
};
|
||||
};
|
||||
|
||||
const createStreamManager = () => {
|
||||
// Emitter that replicates stream messages to registered callbacks
|
||||
const streamProxy = new Emitter<EventMap>();
|
||||
|
||||
const streams = new Map<string, StreamMapValue>();
|
||||
|
||||
getDdpSdk().account.onLogout(() => {
|
||||
streams.forEach((stream) => {
|
||||
stream.unsubList.forEach((stop) => stop());
|
||||
});
|
||||
});
|
||||
|
||||
if (!sdkTransportEnabled) {
|
||||
// In legacy Meteor mode, stream frames arrive on Meteor.connection._stream
|
||||
// as `changed` collection messages — bridge them into streamProxy so the
|
||||
// per-stream callbacks fire. With SDK transport on, the frames arrive on
|
||||
// the SDK socket and createNewDdpSdkStream registers its own onCollection
|
||||
// listener instead.
|
||||
Meteor.connection._stream!.on('message', (rawMsg: string) => {
|
||||
const msg = parseDDP(rawMsg);
|
||||
if (!isChangedCollectionPayload(msg)) {
|
||||
return;
|
||||
}
|
||||
streamProxy.emit(`${msg.collection}/${msg.fields.eventName}` as any, msg.fields.args as any);
|
||||
});
|
||||
}
|
||||
|
||||
const stream: SDK['stream'] = <N extends StreamNames, K extends StreamKeys<N>>(
|
||||
name: N,
|
||||
data: [key: K, ...args: unknown[]],
|
||||
callback: (...args: StreamerCallbackArgs<N, K>) => void,
|
||||
_options?: {
|
||||
retransmit?: boolean | undefined;
|
||||
retransmitToSelf?: boolean | undefined;
|
||||
},
|
||||
): ReturnType<ClientStream['subscribe']> => {
|
||||
const [key, ...args] = data;
|
||||
const eventLiteral = `stream-${name}/${key}` as const;
|
||||
|
||||
const proxyCallback = (args?: unknown): void => {
|
||||
if (!args || !Array.isArray(args)) {
|
||||
throw new Error('Invalid streamer callback');
|
||||
}
|
||||
callback(...(args as StreamerCallbackArgs<N, K>));
|
||||
};
|
||||
|
||||
streamProxy.on(eventLiteral, proxyCallback);
|
||||
|
||||
const stream =
|
||||
streams.get(eventLiteral) ||
|
||||
(sdkTransportEnabled ? createNewDdpSdkStream(streamProxy, name, key, args) : createNewMeteorStream(name, key, args));
|
||||
|
||||
const stop = (): void => {
|
||||
streamProxy.off(eventLiteral, proxyCallback);
|
||||
// If someone is still listening, don't unsubscribe
|
||||
if (streamProxy.has(eventLiteral)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (stream) {
|
||||
stream.stop();
|
||||
streams.delete(eventLiteral);
|
||||
}
|
||||
};
|
||||
|
||||
stream.unsubList.add(stop);
|
||||
if (!streams.has(eventLiteral)) {
|
||||
const offError = stream.onError(() => {
|
||||
stream.unsubList.forEach((stop) => stop());
|
||||
});
|
||||
|
||||
const offStop = stream.onStop(() => {
|
||||
stream.unsubList.forEach((stop) => stop());
|
||||
});
|
||||
|
||||
stream.unsubList.add(offError);
|
||||
stream.unsubList.add(offStop);
|
||||
streams.set(eventLiteral, stream);
|
||||
}
|
||||
|
||||
return {
|
||||
id: '',
|
||||
name,
|
||||
params: data as any,
|
||||
stop,
|
||||
ready: stream.ready,
|
||||
onChange: stream.onChange,
|
||||
isReady: stream.isReady,
|
||||
};
|
||||
};
|
||||
|
||||
const stopAll = (streamName: string, key: string) => {
|
||||
const stream = streams.get(`stream-${streamName}/${key}`);
|
||||
|
||||
if (stream) {
|
||||
stream.unsubList.forEach((stop) => stop());
|
||||
}
|
||||
};
|
||||
|
||||
return { stream, stopAll };
|
||||
};
|
||||
|
||||
// Per-stream wildcard emitters for `onAnyStreamEvent`. Each emitter is fed by
|
||||
// up to two sources (de-duplicated by the underlying bridges):
|
||||
// 1) `getDdpSdk().client.onCollection(...)` — the canonical bridge. Covers
|
||||
// both transport-OFF (via `meteorBackedSdk.onCollection`, which listens
|
||||
// on `Meteor.connection._stream`) and transport-ON (via the real DDPSDK
|
||||
// socket).
|
||||
// 2) When SDK transport is ON, a direct `Meteor.connection._stream` bridge
|
||||
// to catch frames that land on Meteor while the SDK socket is still
|
||||
// authenticating — see `apps/meteor/client/lib/presence.ts`'s fallback
|
||||
// to `Meteor.subscribe('stream-user-presence', ...)`. This is the
|
||||
// "artificial trigger" and is TEMPORARY: it disappears once SDK
|
||||
// transport rollout completes and the Meteor fallback is removed.
|
||||
//
|
||||
// Bridges are wired exactly once per stream name (singleton listeners on the
|
||||
// underlying Meteor stream — Meteor's `on()` has no `off()`, so we can't
|
||||
// detach them per subscription anyway). Consumers register/unregister on the
|
||||
// per-stream Emitter, which DOES support `off`, so unsubscription is clean.
|
||||
const anyStreamEmitters = new Map<string, Emitter<Record<string, [eventName: string, args: unknown[]]>>>();
|
||||
const anyStreamBridged = new Set<string>();
|
||||
|
||||
const createOnAnyStreamEvent = () => {
|
||||
return <N extends StreamNames>(name: N, callback: (eventName: string, args: unknown[]) => void): { stop: () => void } => {
|
||||
const collectionId = `stream-${name}`;
|
||||
|
||||
let emitter = anyStreamEmitters.get(collectionId);
|
||||
if (!emitter) {
|
||||
emitter = new Emitter<Record<string, [eventName: string, args: unknown[]]>>();
|
||||
anyStreamEmitters.set(collectionId, emitter);
|
||||
}
|
||||
|
||||
if (!anyStreamBridged.has(collectionId)) {
|
||||
anyStreamBridged.add(collectionId);
|
||||
const bridgeEmitter = emitter;
|
||||
|
||||
// Primary bridge — works for both transport-OFF (meteorBackedSdk's
|
||||
// onCollection listens on Meteor.connection._stream) and transport-ON
|
||||
// (real DDPSDK socket).
|
||||
getDdpSdk().client.onCollection(collectionId, (data: unknown) => {
|
||||
if (!isChangedCollectionPayload(data)) return;
|
||||
if (data.collection !== collectionId) return;
|
||||
bridgeEmitter.emit('event', [data.fields.eventName, data.fields.args]);
|
||||
});
|
||||
|
||||
// Temporary secondary bridge — see block comment above. Only needed
|
||||
// while SDK transport is on AND Meteor.connection may still receive
|
||||
// frames during the SDK socket's anonymous window.
|
||||
if (sdkTransportEnabled) {
|
||||
Meteor.connection._stream!.on('message', (rawMsg: string) => {
|
||||
let msg: unknown;
|
||||
try {
|
||||
msg = parseDDP(rawMsg);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!isChangedCollectionPayload(msg)) return;
|
||||
if (msg.collection !== collectionId) return;
|
||||
bridgeEmitter.emit('event', [msg.fields.eventName, msg.fields.args]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const handler = ([eventName, args]: [string, unknown[]]): void => {
|
||||
callback(eventName, args);
|
||||
};
|
||||
emitter.on('event', handler);
|
||||
|
||||
return {
|
||||
stop: () => {
|
||||
emitter.off('event', handler);
|
||||
},
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export const createSDK = (rest: RestClientInterface) => {
|
||||
const { stream, stopAll } = createStreamManager();
|
||||
const onAnyStreamEvent = createOnAnyStreamEvent();
|
||||
|
||||
const publish = sdkTransportEnabled
|
||||
? (name: string, args: unknown[]) => {
|
||||
// DDPSDK queues outbound frames until the WebSocket handshake completes,
|
||||
// so there's no need to gate on an isReady flag here.
|
||||
void getDdpSdk().client.callAsync(`stream-${name}`, ...args);
|
||||
}
|
||||
: (name: string, args: unknown[]) => {
|
||||
Meteor.call(`stream-${name}`, ...args);
|
||||
};
|
||||
|
||||
const call = <T extends keyof ServerMethods>(method: T, ...args: Parameters<ServerMethods[T]>): Promise<ReturnType<ServerMethods[T]>> => {
|
||||
return Meteor.callAsync(method, ...args);
|
||||
};
|
||||
|
||||
return {
|
||||
rest,
|
||||
stop: stopAll,
|
||||
stream,
|
||||
onAnyStreamEvent,
|
||||
publish,
|
||||
call,
|
||||
};
|
||||
};
|
||||
|
||||
export const sdk = createSDK(APIClient);
|
||||
178
apps/meteor/client/lib/UserAction.ts
Normal file
178
apps/meteor/client/lib/UserAction.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import type { IExtras, IRoomActivity, IUser } from '@rocket.chat/core-typings';
|
||||
import { Emitter } from '@rocket.chat/emitter';
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
import { sdk } from './SDKClient';
|
||||
import { settings } from './settings';
|
||||
import { getUser, getUserId } from './user';
|
||||
import { Users } from '../stores';
|
||||
|
||||
const TIMEOUT = 15000;
|
||||
const RENEW = TIMEOUT / 3;
|
||||
|
||||
const USER_ACTIVITY = 'user-activity';
|
||||
|
||||
export const USER_ACTIVITIES = {
|
||||
USER_RECORDING: 'user-recording',
|
||||
USER_TYPING: 'user-typing',
|
||||
USER_UPLOADING: 'user-uploading',
|
||||
USER_PLAYING: 'user-playing',
|
||||
};
|
||||
|
||||
const activityTimeouts = new Map();
|
||||
const activityRenews = new Map();
|
||||
const continuingIntervals = new Map();
|
||||
const roomActivities = new Map<string, Set<string>>();
|
||||
const rooms = new Map<string, (username: string, activityType: string[], extras?: object) => void>();
|
||||
|
||||
const performingUsers = new Map<string, IRoomActivity>();
|
||||
const performingUsersEmitter = new Emitter<{ changed: void }>();
|
||||
|
||||
const shownName = function (user: IUser | null | undefined): string | undefined {
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
if (settings.peek('UI_Use_Real_Name')) {
|
||||
return user.name;
|
||||
}
|
||||
return user.username;
|
||||
};
|
||||
|
||||
const emitActivities = debounce(async (rid: string, extras: IExtras): Promise<void> => {
|
||||
const activities = roomActivities.get(extras?.tmid || rid) || new Set();
|
||||
sdk.publish('notify-room', [`${rid}/${USER_ACTIVITY}`, shownName(getUser()), [...activities], extras]);
|
||||
}, 500);
|
||||
|
||||
function handleStreamAction(rid: string, username: string, activityTypes: string[], extras?: IExtras): void {
|
||||
rid = extras?.tmid || rid;
|
||||
const roomActivities = { ...performingUsers.get(rid) };
|
||||
|
||||
for (const [, activity] of Object.entries(USER_ACTIVITIES)) {
|
||||
roomActivities[activity] = roomActivities[activity] || new Map();
|
||||
const users = roomActivities[activity];
|
||||
const timeout = users[username];
|
||||
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
if (activityTypes.includes(activity)) {
|
||||
activityTypes.splice(activityTypes.indexOf(activity), 1);
|
||||
users[username] = setTimeout(() => handleStreamAction(rid, username, activityTypes, extras), TIMEOUT);
|
||||
} else {
|
||||
delete users[username];
|
||||
}
|
||||
}
|
||||
|
||||
performingUsers.set(rid, roomActivities);
|
||||
performingUsersEmitter.emit('changed');
|
||||
}
|
||||
export const UserAction = new (class {
|
||||
addStream(rid: string): () => void {
|
||||
if (rooms.get(rid)) {
|
||||
throw new Error('UserAction - addStream should only be called once per room');
|
||||
}
|
||||
|
||||
const handler = function (username: string, activityType: string[], extras?: object): void {
|
||||
const uid = getUserId();
|
||||
const user = uid ? Users.state.get(uid) : undefined;
|
||||
|
||||
if (username === shownName(user)) {
|
||||
return;
|
||||
}
|
||||
handleStreamAction(rid, username, activityType, extras);
|
||||
};
|
||||
rooms.set(rid, handler);
|
||||
|
||||
const { stop } = sdk.stream('notify-room', [`${rid}/${USER_ACTIVITY}`], handler);
|
||||
return () => {
|
||||
if (!rooms.get(rid)) {
|
||||
return;
|
||||
}
|
||||
stop();
|
||||
rooms.delete(rid);
|
||||
};
|
||||
}
|
||||
|
||||
performContinuously(rid: string, activityType: string, extras: IExtras = {}): void {
|
||||
const trid = extras?.tmid || rid;
|
||||
const key = `${activityType}-${trid}`;
|
||||
|
||||
if (continuingIntervals.get(key)) {
|
||||
return;
|
||||
}
|
||||
this.start(rid, activityType, extras);
|
||||
|
||||
continuingIntervals.set(
|
||||
key,
|
||||
setInterval(() => {
|
||||
this.start(rid, activityType, extras);
|
||||
}, RENEW),
|
||||
);
|
||||
}
|
||||
|
||||
start(rid: string, activityType: string, extras: IExtras = {}): void {
|
||||
const trid = extras?.tmid || rid;
|
||||
const key = `${activityType}-${trid}`;
|
||||
|
||||
if (activityRenews.get(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
activityRenews.set(
|
||||
key,
|
||||
setTimeout(() => {
|
||||
clearTimeout(activityRenews.get(key));
|
||||
activityRenews.delete(key);
|
||||
}, RENEW),
|
||||
);
|
||||
|
||||
const activities = roomActivities.get(trid) || new Set();
|
||||
activities.add(activityType);
|
||||
roomActivities.set(trid, activities);
|
||||
|
||||
void emitActivities(rid, extras);
|
||||
|
||||
if (activityTimeouts.get(key)) {
|
||||
clearTimeout(activityTimeouts.get(key));
|
||||
activityTimeouts.delete(key);
|
||||
}
|
||||
|
||||
activityTimeouts.set(
|
||||
key,
|
||||
setTimeout(() => this.stop(trid, activityType, extras), TIMEOUT),
|
||||
);
|
||||
activityTimeouts.get(key);
|
||||
}
|
||||
|
||||
stop(rid: string, activityType: string, extras: IExtras): void {
|
||||
const trid = extras?.tmid || rid;
|
||||
const key = `${activityType}-${trid}`;
|
||||
|
||||
if (activityTimeouts.get(key)) {
|
||||
clearTimeout(activityTimeouts.get(key));
|
||||
activityTimeouts.delete(key);
|
||||
}
|
||||
if (activityRenews.get(key)) {
|
||||
clearTimeout(activityRenews.get(key));
|
||||
activityRenews.delete(key);
|
||||
}
|
||||
if (continuingIntervals.get(key)) {
|
||||
clearInterval(continuingIntervals.get(key));
|
||||
continuingIntervals.delete(key);
|
||||
}
|
||||
|
||||
const activities = roomActivities.get(trid) || new Set();
|
||||
activities.delete(activityType);
|
||||
roomActivities.set(trid, activities);
|
||||
void emitActivities(rid, extras);
|
||||
}
|
||||
|
||||
get(roomId: string): IRoomActivity | undefined {
|
||||
return performingUsers.get(roomId);
|
||||
}
|
||||
|
||||
subscribe(onChanged: () => void): () => void {
|
||||
return performingUsersEmitter.on('changed', onChanged);
|
||||
}
|
||||
})();
|
||||
791
apps/meteor/client/lib/VideoConfManager.ts
Normal file
791
apps/meteor/client/lib/VideoConfManager.ts
Normal file
@@ -0,0 +1,791 @@
|
||||
import type { CallPreferences, DirectCallData, DirectCallParams, IRoom, IUser, ProviderCapabilities } from '@rocket.chat/core-typings';
|
||||
import { Emitter } from '@rocket.chat/emitter';
|
||||
|
||||
import { sdk } from './SDKClient';
|
||||
import { getConfig } from './utils/getConfig';
|
||||
|
||||
const debug = !!(getConfig('debug') || getConfig('debug-VideoConf'));
|
||||
|
||||
// The interval between attempts to call the remote user
|
||||
const CALL_INTERVAL = 3000;
|
||||
// How many attempts to call we're gonna make
|
||||
const CALL_ATTEMPT_LIMIT = 10;
|
||||
// The amount of time we'll assume an incoming call is still valid without any updates from the remote user
|
||||
const CALL_TIMEOUT = 10000;
|
||||
// How long are we gonna wait for a link after accepting an incoming call
|
||||
const ACCEPT_TIMEOUT = 5000;
|
||||
|
||||
type IncomingDirectCall = DirectCallParams & {
|
||||
timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
acceptTimeout?: ReturnType<typeof setTimeout> | undefined;
|
||||
};
|
||||
|
||||
type CurrentCallParams = {
|
||||
callId: string;
|
||||
url: string;
|
||||
providerName?: string;
|
||||
};
|
||||
|
||||
type VideoConfEvents = {
|
||||
// We gave up on calling a remote user or they rejected our call
|
||||
'direct/cancel': DirectCallParams;
|
||||
|
||||
// A remote user is calling us
|
||||
'direct/ringing': DirectCallParams;
|
||||
|
||||
// An incoming call was lost, either by timeout or because the remote user canceled
|
||||
'direct/lost': DirectCallParams;
|
||||
|
||||
// We tried to accept an incoming call but the process failed
|
||||
'direct/failed': DirectCallParams;
|
||||
|
||||
// A remote user accepted our call
|
||||
'direct/accepted': DirectCallParams;
|
||||
|
||||
// We stopped calling a remote user
|
||||
'direct/stopped': DirectCallParams;
|
||||
|
||||
'preference/changed': { key: keyof CallPreferences; value: boolean };
|
||||
|
||||
// The list of incoming calls has changed in some way
|
||||
'incoming/changed': void;
|
||||
|
||||
// The list of ringing incoming calls may have changed
|
||||
'ringing/changed': void;
|
||||
|
||||
// The value of `isCalling` may have changed
|
||||
'calling/changed': void;
|
||||
|
||||
'calling/ended': void;
|
||||
|
||||
// When join call
|
||||
'call/join': CurrentCallParams;
|
||||
|
||||
'error': { error: string };
|
||||
|
||||
'capabilities/changed': void;
|
||||
};
|
||||
|
||||
export const VideoConfManager = new (class VideoConfManager extends Emitter<VideoConfEvents> {
|
||||
private userId: string | undefined;
|
||||
|
||||
private currentCallHandler: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
private currentCallData: (DirectCallParams & { joined?: boolean }) | undefined;
|
||||
|
||||
private startingNewCall = false;
|
||||
|
||||
private hooks: (() => void)[] = [];
|
||||
|
||||
private incomingDirectCalls: Map<string, IncomingDirectCall>;
|
||||
|
||||
private directCalls: DirectCallData[] = [];
|
||||
|
||||
private dismissedCalls: Set<string>;
|
||||
|
||||
private _preferences: CallPreferences;
|
||||
|
||||
private _capabilities: ProviderCapabilities;
|
||||
|
||||
private _logLevel: number;
|
||||
|
||||
public get preferences(): CallPreferences {
|
||||
return this._preferences;
|
||||
}
|
||||
|
||||
public get capabilities(): ProviderCapabilities {
|
||||
return this._capabilities;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._logLevel = 0;
|
||||
this.incomingDirectCalls = new Map<string, IncomingDirectCall>();
|
||||
this.dismissedCalls = new Set<string>();
|
||||
this._preferences = { mic: true, cam: false };
|
||||
this._capabilities = {};
|
||||
|
||||
this.on('incoming/changed', () => {
|
||||
this.directCalls = [...this.incomingDirectCalls.values()]
|
||||
// Filter out any calls that we're in the process of accepting, so they're already hidden from the UI
|
||||
.filter((call) => !call.acceptTimeout)
|
||||
.map(({ timeout: _, acceptTimeout: _t, ...call }) => ({ ...call, dismissed: this.isCallDismissed(call.callId) }));
|
||||
});
|
||||
}
|
||||
|
||||
public isBusy(): boolean {
|
||||
if (this.startingNewCall) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return this.isCalling();
|
||||
}
|
||||
|
||||
public isRinging(): boolean {
|
||||
return [...this.incomingDirectCalls.values()].some(({ callId }) => !this.isCallDismissed(callId));
|
||||
}
|
||||
|
||||
public isCalling(): boolean {
|
||||
if (this.currentCallHandler || (this.currentCallData && !this.currentCallData.joined)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public getIncomingDirectCalls(): DirectCallData[] {
|
||||
return this.directCalls;
|
||||
}
|
||||
|
||||
public async startCall(roomId: IRoom['_id'], title?: string): Promise<void> {
|
||||
if (!this.userId || this.isBusy()) {
|
||||
this.emitError('error-videoconf-cant-start-call-with-manager-busy');
|
||||
throw new Error('Video manager is busy.');
|
||||
}
|
||||
|
||||
this.debugLog(`[VideoConf] Starting new call on room ${roomId}`);
|
||||
this.startingNewCall = true;
|
||||
this.emit('calling/changed');
|
||||
|
||||
const { data } = await sdk.rest.post('/v1/video-conference.start', { roomId, title, allowRinging: true }).catch((e: any) => {
|
||||
console.error(`[VideoConf] Failed to start new call on room ${roomId}`, e);
|
||||
this.startingNewCall = false;
|
||||
this.emit('calling/changed');
|
||||
this.emitError(e?.xhr?.responseJSON?.error || 'error-videoconf-unexpected');
|
||||
|
||||
return Promise.reject(e);
|
||||
});
|
||||
|
||||
this.startingNewCall = false;
|
||||
this.emit('calling/changed');
|
||||
|
||||
if (data.type !== 'direct') {
|
||||
this.emit('calling/ended');
|
||||
}
|
||||
|
||||
switch (data.type) {
|
||||
case 'direct':
|
||||
return this.callUser({ uid: data.calleeId, rid: roomId, callId: data.callId });
|
||||
case 'videoconference':
|
||||
return this.joinCall(data.callId);
|
||||
case 'livechat':
|
||||
return this.joinCall(data.callId);
|
||||
}
|
||||
}
|
||||
|
||||
public acceptIncomingCall(callId: string): void {
|
||||
const callData = this.incomingDirectCalls.get(callId);
|
||||
if (!callData) {
|
||||
this.emitError();
|
||||
throw new Error('Unable to find accepted call information.');
|
||||
}
|
||||
if (callData.acceptTimeout) {
|
||||
this.debugLog(`[VideoConf] We're already trying to accept call ${callId}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.debugLog(`[VideoConf] Accepting incoming call ${callId}.`);
|
||||
|
||||
if (callData.timeout) {
|
||||
clearTimeout(callData.timeout);
|
||||
this.setIncomingCallAttribute(callId, 'timeout', undefined);
|
||||
}
|
||||
|
||||
// Mute this call Id so any lingering notifications don't trigger it again
|
||||
this.dismissIncomingCall(callId);
|
||||
|
||||
this.setIncomingCallAttribute(
|
||||
callId,
|
||||
'acceptTimeout',
|
||||
setTimeout(() => {
|
||||
const updatedCallData = this.incomingDirectCalls.get(callId);
|
||||
if (!updatedCallData?.acceptTimeout) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.debugLog(`[VideoConf] Attempt to accept call has timed out.`);
|
||||
this.removeIncomingCall(callId);
|
||||
|
||||
this.emit('direct/failed', { callId, uid: callData.uid, rid: callData.rid });
|
||||
this.emitError('error-videoconf-direct-call-accept-timeout');
|
||||
}, ACCEPT_TIMEOUT),
|
||||
);
|
||||
this.emit('incoming/changed');
|
||||
|
||||
this.debugLog(`[VideoConf] Notifying user ${callData.uid} that we accept their call.`);
|
||||
this.userId && this.notifyUser(callData.uid, 'accepted', { callId, uid: this.userId, rid: callData.rid });
|
||||
}
|
||||
|
||||
public rejectIncomingCall(callId: string): void {
|
||||
this.dismissIncomingCall(callId);
|
||||
|
||||
const callData = this.incomingDirectCalls.get(callId);
|
||||
if (!callData) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.userId && this.notifyUser(callData.uid, 'rejected', { callId, uid: this.userId, rid: callData.rid });
|
||||
this.loseIncomingCall(callId);
|
||||
}
|
||||
|
||||
public dismissedIncomingCalls(): void {
|
||||
// Mute all calls that are currently ringing
|
||||
if ([...this.incomingDirectCalls.keys()].some((callId) => this.dismissedIncomingCallHelper(callId))) {
|
||||
this.emit('ringing/changed');
|
||||
this.emit('incoming/changed');
|
||||
}
|
||||
}
|
||||
|
||||
public async loadCapabilities(): Promise<void> {
|
||||
const { capabilities } = await sdk.rest.get('/v1/video-conference.capabilities').catch((e: any) => {
|
||||
console.error(`[VideoConf] Failed to load video conference capabilities`, e);
|
||||
this.emitError();
|
||||
|
||||
return Promise.reject(e);
|
||||
});
|
||||
|
||||
this._capabilities = capabilities || {};
|
||||
this.emit('capabilities/changed');
|
||||
}
|
||||
|
||||
private setIncomingCallAttribute<T extends keyof IncomingDirectCall>(
|
||||
callId: string,
|
||||
attributeName: T,
|
||||
value: IncomingDirectCall[T] | undefined,
|
||||
): void {
|
||||
const callData = this.incomingDirectCalls.get(callId);
|
||||
if (!callData) {
|
||||
console.error(`[VideoConf] Cannot change attribute "${attributeName}" of unknown call "${callId}".`);
|
||||
return;
|
||||
}
|
||||
|
||||
const newData: IncomingDirectCall = {
|
||||
...callData,
|
||||
};
|
||||
|
||||
if (value === undefined) {
|
||||
delete newData[attributeName];
|
||||
} else {
|
||||
newData[attributeName] = value;
|
||||
}
|
||||
|
||||
this.debugLog(`[VideoConf] Updating attribute "${attributeName}" of call "${callId}".`);
|
||||
this.incomingDirectCalls.set(callId, newData);
|
||||
}
|
||||
|
||||
private emitError(error = 'error-videoconf-unexpected'): void {
|
||||
this.emit('error', { error });
|
||||
}
|
||||
|
||||
private dismissedIncomingCallHelper(callId: string): boolean {
|
||||
// Muting will stop a callId from ringing, but it doesn't affect any part of the existing workflow
|
||||
if (this.isCallDismissed(callId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.debugLog(`[VideoConf] Dismissing call ${callId}`);
|
||||
this.dismissedCalls.add(callId);
|
||||
// We don't need to hold on to the dismissed callIds forever because the server won't let anyone call us with it for very long
|
||||
setTimeout(() => this.dismissedCalls.delete(callId), CALL_TIMEOUT * 20);
|
||||
// Only change the state if this call is actually in our list
|
||||
return this.incomingDirectCalls.has(callId);
|
||||
}
|
||||
|
||||
public dismissIncomingCall(callId: string): boolean {
|
||||
if (this.dismissedIncomingCallHelper(callId)) {
|
||||
this.emit('ringing/changed');
|
||||
this.emit('incoming/changed');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public updateUser(userId: string | null, isLoggingIn: boolean, isConnected: boolean): void {
|
||||
if (userId === this.userId && !isLoggingIn && !isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.debugLog(`[VideoConf] Logged user or connection status has changed.`);
|
||||
|
||||
if (this.userId) {
|
||||
this.disconnect(this.userId !== userId);
|
||||
}
|
||||
|
||||
if (!isConnected || (userId && isLoggingIn)) {
|
||||
this.debugLog(`[VideoConf] Connection lost or login process still pending, skipping user change.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (userId) {
|
||||
this.connectUser(userId);
|
||||
}
|
||||
}
|
||||
|
||||
public changePreference(key: keyof CallPreferences, value: boolean): void {
|
||||
this._preferences[key] = value;
|
||||
this.emit('preference/changed', { key, value });
|
||||
}
|
||||
|
||||
public setPreferences(prefs: Partial<CallPreferences>): void {
|
||||
for (const key in prefs) {
|
||||
if (prefs.hasOwnProperty(key)) {
|
||||
const prefKey = key as keyof CallPreferences;
|
||||
this.changePreference(prefKey, prefs[prefKey] as boolean);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public setLogLevel(level: number): void {
|
||||
this._logLevel = Math.max(0, Math.min(level, 2));
|
||||
}
|
||||
|
||||
public async joinCall(callId: string): Promise<void> {
|
||||
this.debugLog(`[VideoConf] Joining call ${callId}.`);
|
||||
|
||||
if (this.incomingDirectCalls.has(callId)) {
|
||||
const data = this.incomingDirectCalls.get(callId);
|
||||
if (data?.acceptTimeout) {
|
||||
this.debugLog('[VideoConf] Clearing acceptance timeout');
|
||||
clearTimeout(data.acceptTimeout);
|
||||
}
|
||||
this.removeIncomingCall(callId);
|
||||
}
|
||||
|
||||
const params = {
|
||||
callId,
|
||||
state: {
|
||||
...(this._preferences.mic !== undefined ? { mic: this._preferences.mic } : {}),
|
||||
...(this._preferences.cam !== undefined ? { cam: this._preferences.cam } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
const { url, providerName } = await sdk.rest.post('/v1/video-conference.join', params).catch((e) => {
|
||||
console.error(`[VideoConf] Failed to join call ${callId}`, e);
|
||||
this.emitError(e?.xhr?.responseJSON?.error || 'error-videoconf-join-failed');
|
||||
|
||||
return Promise.reject(e);
|
||||
});
|
||||
|
||||
if (!url) {
|
||||
this.emitError('error-videoconf-missing-url');
|
||||
throw new Error('Failed to get video conference URL.');
|
||||
}
|
||||
|
||||
this.debugLog(`[VideoConf] Opening ${url}.`);
|
||||
this.emit('call/join', { url, callId, providerName });
|
||||
}
|
||||
|
||||
public abortCall(): void {
|
||||
if (!this.currentCallData) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.giveUp(this.currentCallData);
|
||||
}
|
||||
|
||||
private infoLog(...args: any[]): void {
|
||||
(debug || this._logLevel >= 1) && console.log(...args);
|
||||
}
|
||||
|
||||
private warnLog(...args: any[]): void {
|
||||
(debug || this._logLevel >= 1) && console.warn(...args);
|
||||
}
|
||||
|
||||
private debugLog(...args: any[]): void {
|
||||
(debug || this._logLevel >= 2) && console.log(...args);
|
||||
}
|
||||
|
||||
private rejectIncomingCallsFromUser(userId: string): void {
|
||||
for (const [, { callId, uid }] of this.incomingDirectCalls) {
|
||||
if (userId === uid) {
|
||||
this.debugLog(`[VideoConf] Rejecting old incoming call from user ${userId}`);
|
||||
this.rejectIncomingCall(callId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async callUser({ uid, rid, callId }: DirectCallParams): Promise<void> {
|
||||
if (this.currentCallHandler || this.currentCallData) {
|
||||
this.emitError('error-videoconf-cant-start-call-with-manager-busy');
|
||||
throw new Error('Video Conference State Error.');
|
||||
}
|
||||
|
||||
let attempt = 1;
|
||||
this.currentCallData = { callId, rid, uid };
|
||||
this.currentCallHandler = setInterval(() => {
|
||||
if (!this.currentCallHandler) {
|
||||
this.warnLog(`[VideoConf] Ringing interval was not properly cleared.`);
|
||||
return;
|
||||
}
|
||||
|
||||
attempt++;
|
||||
|
||||
if (attempt > CALL_ATTEMPT_LIMIT) {
|
||||
this.giveUp({ uid, rid, callId });
|
||||
return;
|
||||
}
|
||||
|
||||
this.debugLog(`[VideoConf] Ringing user ${uid}, attempt number ${attempt}.`);
|
||||
this.userId && this.notifyUser(uid, 'call', { uid: this.userId, rid, callId });
|
||||
}, CALL_INTERVAL);
|
||||
this.emit('calling/changed');
|
||||
|
||||
this.debugLog(`[VideoConf] Ringing user ${uid} for the first time.`);
|
||||
this.userId && this.notifyUser(uid, 'call', { uid: this.userId, rid, callId });
|
||||
}
|
||||
|
||||
private async giveUp({ uid, rid, callId }: DirectCallParams): Promise<void> {
|
||||
const joined = this.currentCallData?.joined;
|
||||
|
||||
this.debugLog(`[VideoConf] Stop ringing user ${uid}.`);
|
||||
if (this.currentCallHandler) {
|
||||
clearInterval(this.currentCallHandler);
|
||||
this.currentCallHandler = undefined;
|
||||
this.currentCallData = undefined;
|
||||
this.emit('calling/changed');
|
||||
}
|
||||
|
||||
this.debugLog(`[VideoConf] Notifying user ${uid} that we are no longer calling.`);
|
||||
this.userId && this.notifyUser(uid, 'canceled', { uid: this.userId, rid, callId });
|
||||
|
||||
this.emit('direct/cancel', { uid, rid, callId });
|
||||
this.emit('direct/stopped', { uid, rid, callId });
|
||||
|
||||
if (joined) {
|
||||
return;
|
||||
}
|
||||
|
||||
sdk.rest.post('/v1/video-conference.cancel', { callId });
|
||||
}
|
||||
|
||||
private disconnect(clearCalls = true): void {
|
||||
console.log(`[VideoConf] disconnecting user ${this.userId}`);
|
||||
for (const hook of this.hooks) {
|
||||
hook();
|
||||
}
|
||||
this.hooks = [];
|
||||
this.userId = undefined;
|
||||
|
||||
if (!clearCalls) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.currentCallHandler) {
|
||||
clearInterval(this.currentCallHandler);
|
||||
this.currentCallHandler = undefined;
|
||||
}
|
||||
|
||||
this.incomingDirectCalls.forEach((call) => {
|
||||
if (call.timeout) {
|
||||
clearTimeout(call.timeout);
|
||||
}
|
||||
if (call.acceptTimeout) {
|
||||
clearTimeout(call.acceptTimeout);
|
||||
}
|
||||
});
|
||||
this.incomingDirectCalls.clear();
|
||||
this.dismissedCalls.clear();
|
||||
this.currentCallData = undefined;
|
||||
this._preferences = {};
|
||||
this.emit('incoming/changed');
|
||||
this.emit('ringing/changed');
|
||||
this.emit('calling/changed');
|
||||
}
|
||||
|
||||
private async onVideoConfNotification({ action, params }: { action: string; params: DirectCallParams }): Promise<void> {
|
||||
if (!action || typeof action !== 'string') {
|
||||
this.warnLog('[VideoConf] Invalid action received.', action, params);
|
||||
return;
|
||||
}
|
||||
if (!params || typeof params !== 'object' || !params.callId || !params.uid || !params.rid) {
|
||||
this.warnLog('[VideoConf] Invalid params received.', action, params);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'call':
|
||||
return this.onDirectCall(params);
|
||||
case 'canceled':
|
||||
return this.onDirectCallCanceled(params);
|
||||
case 'accepted':
|
||||
return this.onDirectCallAccepted(params);
|
||||
case 'rejected':
|
||||
return this.onDirectCallRejected(params);
|
||||
case 'confirmed':
|
||||
return this.onDirectCallConfirmed(params);
|
||||
case 'join':
|
||||
return this.onDirectCallJoined(params);
|
||||
case 'end':
|
||||
return this.onDirectCallEnded(params);
|
||||
}
|
||||
}
|
||||
|
||||
private async notifyUser(uid: IUser['_id'], action: string, params: DirectCallParams): Promise<void> {
|
||||
return sdk.publish('notify-user', [`${uid}/video-conference`, { action, params }]);
|
||||
}
|
||||
|
||||
private async connectUser(userId: string): Promise<void> {
|
||||
console.log(`[VideoConf] connecting user ${userId}`);
|
||||
this.userId = userId;
|
||||
|
||||
const { stop, ready } = sdk.stream('notify-user', [`${userId}/video-conference`], (data) => this.onVideoConfNotification(data));
|
||||
|
||||
await ready();
|
||||
|
||||
this.hooks.push(stop);
|
||||
}
|
||||
|
||||
private abortIncomingCall(callId: string): void {
|
||||
// If we just accepted this call, then ignore the timeout
|
||||
if (this.incomingDirectCalls.get(callId)?.acceptTimeout) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.infoLog(`[VideoConf] Canceling call ${callId} due to ringing timeout.`);
|
||||
this.loseIncomingCall(callId);
|
||||
}
|
||||
|
||||
private loseIncomingCall(callId: string): void {
|
||||
const lostCall = this.incomingDirectCalls.get(callId);
|
||||
if (!lostCall) {
|
||||
this.warnLog(`[VideoConf] Unable to cancel ${callId} because we have no information about it.`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.removeIncomingCall(callId);
|
||||
|
||||
this.debugLog(`[VideoConf] Call ${callId} from ${lostCall.uid} was lost.`);
|
||||
this.emit('direct/lost', { callId, uid: lostCall.uid, rid: lostCall.rid });
|
||||
}
|
||||
|
||||
private removeIncomingCall(callId: string): void {
|
||||
this.debugLog(`[VideoConf] Removing call with id "${callId}" from Incoming Calls list.`);
|
||||
if (!this.incomingDirectCalls.has(callId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isRinging = this.isRinging();
|
||||
|
||||
const callData = this.incomingDirectCalls.get(callId);
|
||||
if (callData?.timeout) {
|
||||
clearTimeout(callData.timeout);
|
||||
}
|
||||
|
||||
this.incomingDirectCalls.delete(callId);
|
||||
this.emit('incoming/changed');
|
||||
|
||||
if (isRinging !== this.isRinging()) {
|
||||
this.emit('ringing/changed');
|
||||
}
|
||||
}
|
||||
|
||||
private createAbortTimeout(callId: string): ReturnType<typeof setTimeout> {
|
||||
return setTimeout(() => this.abortIncomingCall(callId), CALL_TIMEOUT);
|
||||
}
|
||||
|
||||
private startNewIncomingCall({ callId, uid, rid }: DirectCallParams): void {
|
||||
if (this.isCallDismissed(callId)) {
|
||||
this.debugLog(`[VideoConf] Ignoring dismissed call.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Reject any currently ringing call from the user before registering the new one.
|
||||
this.rejectIncomingCallsFromUser(uid);
|
||||
|
||||
this.debugLog(`[VideoConf] Storing this new call information.`);
|
||||
this.incomingDirectCalls.set(callId, {
|
||||
callId,
|
||||
uid,
|
||||
rid,
|
||||
timeout: this.createAbortTimeout(callId),
|
||||
});
|
||||
|
||||
this.emit('incoming/changed');
|
||||
this.emit('ringing/changed');
|
||||
this.emit('direct/ringing', { callId, uid, rid });
|
||||
}
|
||||
|
||||
private refreshExistingIncomingCall({ callId, uid, rid }: DirectCallParams): void {
|
||||
const existingData = this.incomingDirectCalls.get(callId);
|
||||
if (!existingData) {
|
||||
throw new Error('Video Conference Manager State Error');
|
||||
}
|
||||
|
||||
this.debugLog(`[VideoConf] Resetting call timeout.`);
|
||||
if (existingData.timeout) {
|
||||
clearTimeout(existingData.timeout);
|
||||
}
|
||||
existingData.timeout = this.createAbortTimeout(callId);
|
||||
|
||||
if (!this.isCallDismissed(callId)) {
|
||||
this.emit('direct/ringing', { callId, uid, rid });
|
||||
}
|
||||
}
|
||||
|
||||
private onDirectCall({ callId, uid, rid }: DirectCallParams): void {
|
||||
// If we already accepted this call, then don't ring again
|
||||
if (this.incomingDirectCalls.get(callId)?.acceptTimeout) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.infoLog(`[VideoConf] User ${uid} is ringing with call ${callId}.`);
|
||||
if (this.incomingDirectCalls.has(callId)) {
|
||||
this.refreshExistingIncomingCall({ callId, uid, rid });
|
||||
} else {
|
||||
this.startNewIncomingCall({ callId, uid, rid });
|
||||
}
|
||||
}
|
||||
|
||||
private onDirectCallCanceled({ callId }: DirectCallParams): void {
|
||||
this.infoLog(`[VideoConf] Call ${callId} was canceled by the remote user.`);
|
||||
|
||||
// We had just accepted this call, but the remote user hang up before they got the notification, so cancel our acceptance
|
||||
const callData = this.incomingDirectCalls.get(callId);
|
||||
if (callData?.acceptTimeout) {
|
||||
this.emitError('error-videoconf-direct-call-accept-canceled');
|
||||
clearTimeout(callData.acceptTimeout);
|
||||
this.setIncomingCallAttribute(callId, 'acceptTimeout', undefined);
|
||||
}
|
||||
|
||||
this.loseIncomingCall(callId);
|
||||
}
|
||||
|
||||
private onDirectCallAccepted(params: DirectCallParams, skipConfirmation = false): void {
|
||||
if (!params.callId || params.callId !== this.currentCallData?.callId) {
|
||||
this.debugLog(`[VideoConf] User ${params.uid} has accepted a call ${params.callId} from us, but we're not calling.`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.infoLog(`[VideoConf] User ${params.uid} has accepted our call ${params.callId}.`);
|
||||
|
||||
// Stop ringing
|
||||
if (this.currentCallHandler) {
|
||||
clearInterval(this.currentCallHandler);
|
||||
this.currentCallHandler = undefined;
|
||||
}
|
||||
|
||||
const callData = this.currentCallData;
|
||||
|
||||
this.emit('direct/accepted', params);
|
||||
this.emit('direct/stopped', params);
|
||||
this.currentCallData = undefined;
|
||||
this.emit('calling/changed');
|
||||
|
||||
if (!callData.joined) {
|
||||
this.joinCall(params.callId);
|
||||
}
|
||||
|
||||
if (skipConfirmation) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.debugLog(`[VideoConf] Notifying user ${callData.uid} that they can join the call now.`);
|
||||
this.userId && this.notifyUser(callData.uid, 'confirmed', { callId: callData.callId, uid: this.userId, rid: callData.rid });
|
||||
}
|
||||
|
||||
private onDirectCallConfirmed(params: DirectCallParams): void {
|
||||
if (!params.callId || !this.incomingDirectCalls.get(params.callId)?.acceptTimeout) {
|
||||
this.warnLog(`[VideoConf] User ${params.uid} confirmed we can join ${params.callId} but we aren't trying to join it.`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.joinCall(params.callId);
|
||||
}
|
||||
|
||||
private onDirectCallJoined(params: DirectCallParams): void {
|
||||
if (!params.callId) {
|
||||
this.debugLog(`[VideoConf] Invalid 'video-conference.join' event received: ${params.callId}, ${params.uid}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (params.uid === this.userId) {
|
||||
if (this.currentCallData?.callId === params.callId) {
|
||||
this.debugLog(`[VideoConf] We joined our own call (${this.userId}) from somewhere else. Flagging the call appropriatelly.`);
|
||||
this.currentCallData.joined = true;
|
||||
this.emit('calling/changed');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.incomingDirectCalls.has(params.callId)) {
|
||||
this.debugLog(`[VideoConf] We joined the call ${params.callId} from somewhere else. Dismissing it.`);
|
||||
this.dismissIncomingCall(params.callId);
|
||||
this.loseIncomingCall(params.callId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (params.callId !== this.currentCallData?.callId) {
|
||||
this.debugLog(`[VideoConf] User ${params.uid} has joined the call ${params.callId}, but we aren't calling with that id.`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.infoLog(`[VideoConf] User ${params.uid} has joined a call we started ${params.callId}.`);
|
||||
this.onDirectCallAccepted(params, true);
|
||||
}
|
||||
|
||||
private onDirectCallEnded(params: DirectCallParams): void {
|
||||
if (!params.callId) {
|
||||
this.debugLog(`[VideoConf] Invalid 'video-conference.end' event received: ${params.callId}, ${params.uid}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const callData = this.incomingDirectCalls.get(params.callId);
|
||||
if (callData) {
|
||||
this.infoLog(`[VideoConf] Incoming call ended by the server: ${params.callId}.`);
|
||||
if (callData.acceptTimeout) {
|
||||
this.emitError('error-videoconf-direct-call-accept-ended');
|
||||
clearTimeout(callData.acceptTimeout);
|
||||
this.setIncomingCallAttribute(params.callId, 'acceptTimeout', undefined);
|
||||
}
|
||||
|
||||
this.loseIncomingCall(params.callId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.currentCallData?.callId !== params.callId) {
|
||||
this.infoLog(`[VideoConf] Server sent a call ended event for a call we're not aware of: ${params.callId}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.infoLog(`[VideoConf] Outgoing call ended by the server: ${params.callId}.`);
|
||||
|
||||
// Stop ringing
|
||||
this.currentCallData = undefined;
|
||||
if (this.currentCallHandler) {
|
||||
clearInterval(this.currentCallHandler);
|
||||
this.currentCallHandler = undefined;
|
||||
this.emit('calling/changed');
|
||||
this.emit('direct/stopped', params);
|
||||
}
|
||||
}
|
||||
|
||||
private onDirectCallRejected(params: DirectCallParams): void {
|
||||
if (!params.callId || params.callId !== this.currentCallData?.callId) {
|
||||
this.infoLog(`[VideoConf] User ${params.uid} has rejected a call ${params.callId} from us, but we're not calling.`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.infoLog(`[VideoConf] User ${params.uid} has rejected our call ${params.callId}.`);
|
||||
|
||||
// Stop ringing
|
||||
if (this.currentCallHandler) {
|
||||
clearInterval(this.currentCallHandler);
|
||||
this.currentCallHandler = undefined;
|
||||
}
|
||||
|
||||
const { joined } = this.currentCallData;
|
||||
|
||||
this.emit('direct/cancel', params);
|
||||
this.currentCallData = undefined;
|
||||
this.emit('direct/stopped', params);
|
||||
this.emit('calling/changed');
|
||||
|
||||
if (!joined) {
|
||||
sdk.rest.post('/v1/video-conference.cancel', { callId: params.callId });
|
||||
}
|
||||
}
|
||||
|
||||
private isCallDismissed(callId: string): boolean {
|
||||
return this.dismissedCalls.has(callId);
|
||||
}
|
||||
})();
|
||||
107
apps/meteor/client/lib/absoluteUrl.spec.ts
Normal file
107
apps/meteor/client/lib/absoluteUrl.spec.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { absoluteUrl, _relativeToSiteRootUrl } from './absoluteUrl';
|
||||
|
||||
jest.mock('./baseURI', () => ({
|
||||
baseURI: 'http://localhost:3000/',
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
absoluteUrl.defaultOptions = { rootUrl: 'http://localhost:3000/' };
|
||||
});
|
||||
|
||||
describe('absoluteUrl', () => {
|
||||
it('should return the root URL with a trailing slash when no path is given', () => {
|
||||
expect(absoluteUrl(undefined, { rootUrl: 'http://example.com' })).toBe('http://example.com/');
|
||||
});
|
||||
|
||||
it('should append the path to the root URL', () => {
|
||||
expect(absoluteUrl('foo/bar', { rootUrl: 'http://example.com' })).toBe('http://example.com/foo/bar');
|
||||
});
|
||||
|
||||
it('should strip leading slashes from the path', () => {
|
||||
expect(absoluteUrl('///foo', { rootUrl: 'http://example.com' })).toBe('http://example.com/foo');
|
||||
});
|
||||
|
||||
it('should prepend http:// when the rootUrl has no protocol', () => {
|
||||
expect(absoluteUrl(undefined, { rootUrl: 'example.com' })).toBe('http://example.com/');
|
||||
});
|
||||
|
||||
it('should preserve https:// when already present in rootUrl', () => {
|
||||
expect(absoluteUrl('path', { rootUrl: 'https://example.com' })).toBe('https://example.com/path');
|
||||
});
|
||||
|
||||
it('should throw when rootUrl is not provided and defaultOptions.rootUrl is unset', () => {
|
||||
absoluteUrl.defaultOptions = {};
|
||||
expect(() => absoluteUrl()).toThrow('Must pass options.rootUrl or set ROOT_URL in the server environment');
|
||||
});
|
||||
|
||||
it('should use defaultOptions.rootUrl when no rootUrl option is given', () => {
|
||||
absoluteUrl.defaultOptions = { rootUrl: 'http://default.example.com' };
|
||||
expect(absoluteUrl('test')).toBe('http://default.example.com/test');
|
||||
});
|
||||
|
||||
it('should upgrade http to https when secure is true and host is not localhost', () => {
|
||||
expect(absoluteUrl('path', { rootUrl: 'http://example.com', secure: true })).toBe('https://example.com/path');
|
||||
});
|
||||
|
||||
it('should not upgrade to https for localhost when secure is true', () => {
|
||||
expect(absoluteUrl('path', { rootUrl: 'http://localhost:3000', secure: true })).toBe('http://localhost:3000/path');
|
||||
});
|
||||
|
||||
it('should not upgrade to https for 127.0.0.1 when secure is true', () => {
|
||||
expect(absoluteUrl('path', { rootUrl: 'http://127.0.0.1:3000', secure: true })).toBe('http://127.0.0.1:3000/path');
|
||||
});
|
||||
|
||||
it('should replace localhost with 127.0.0.1 when replaceLocalhost is true', () => {
|
||||
expect(absoluteUrl('path', { rootUrl: 'http://localhost:3000', replaceLocalhost: true })).toBe('http://127.0.0.1:3000/path');
|
||||
});
|
||||
|
||||
it('should not replace localhost when replaceLocalhost is false', () => {
|
||||
expect(absoluteUrl('path', { rootUrl: 'http://localhost:3000', replaceLocalhost: false })).toBe('http://localhost:3000/path');
|
||||
});
|
||||
|
||||
it('should accept options as the first argument when path is omitted', () => {
|
||||
expect(absoluteUrl({ rootUrl: 'http://example.com' } as any)).toBe('http://example.com/');
|
||||
});
|
||||
|
||||
it('should not duplicate trailing slash', () => {
|
||||
expect(absoluteUrl(undefined, { rootUrl: 'http://example.com/' })).toBe('http://example.com/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('_relativeToSiteRootUrl', () => {
|
||||
const originalConfig = (globalThis as any).__meteor_runtime_config__;
|
||||
|
||||
afterEach(() => {
|
||||
(globalThis as any).__meteor_runtime_config__ = originalConfig;
|
||||
});
|
||||
|
||||
it('should prepend ROOT_URL_PATH_PREFIX to links starting with /', () => {
|
||||
(globalThis as any).__meteor_runtime_config__ = { ROOT_URL_PATH_PREFIX: '/subdir' };
|
||||
expect(_relativeToSiteRootUrl('/route')).toBe('/subdir/route');
|
||||
});
|
||||
|
||||
it('should return the link unchanged when it does not start with /', () => {
|
||||
(globalThis as any).__meteor_runtime_config__ = { ROOT_URL_PATH_PREFIX: '/subdir' };
|
||||
expect(_relativeToSiteRootUrl('route')).toBe('route');
|
||||
});
|
||||
|
||||
it('should return the link unchanged when __meteor_runtime_config__ is not an object', () => {
|
||||
delete (globalThis as any).__meteor_runtime_config__;
|
||||
expect(_relativeToSiteRootUrl('/route')).toBe('/route');
|
||||
});
|
||||
|
||||
it('should handle empty ROOT_URL_PATH_PREFIX', () => {
|
||||
(globalThis as any).__meteor_runtime_config__ = { ROOT_URL_PATH_PREFIX: '' };
|
||||
expect(_relativeToSiteRootUrl('/route')).toBe('/route');
|
||||
});
|
||||
});
|
||||
|
||||
describe('defaultOptions', () => {
|
||||
it('should initialize rootUrl from baseURI', () => {
|
||||
expect(absoluteUrl.defaultOptions.rootUrl).toBe('http://localhost:3000/');
|
||||
});
|
||||
|
||||
it('should initialize secure from window.isSecureContext', () => {
|
||||
expect(absoluteUrl.defaultOptions.secure).toBe(window.isSecureContext);
|
||||
});
|
||||
});
|
||||
59
apps/meteor/client/lib/absoluteUrl.ts
Normal file
59
apps/meteor/client/lib/absoluteUrl.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
// There is a good chance this module may be promoted to root lib/ in the future
|
||||
|
||||
import { baseURI } from './baseURI';
|
||||
import { getRootUrlPathPrefix } from './meteorRuntimeConfig';
|
||||
|
||||
type AbsoluteUrlOptions = {
|
||||
rootUrl?: string;
|
||||
secure?: boolean;
|
||||
replaceLocalhost?: boolean;
|
||||
};
|
||||
|
||||
export function absoluteUrl(path?: string, options?: AbsoluteUrlOptions): string {
|
||||
if (!options && typeof path === 'object') {
|
||||
options = path;
|
||||
path = undefined;
|
||||
}
|
||||
|
||||
options = { ...absoluteUrl.defaultOptions, ...options };
|
||||
|
||||
let { rootUrl } = options;
|
||||
|
||||
if (!rootUrl) throw Error('Must pass options.rootUrl or set ROOT_URL in the server environment');
|
||||
|
||||
if (!/^http[s]?:\/\//i.test(rootUrl)) {
|
||||
rootUrl = `http://${rootUrl}`;
|
||||
}
|
||||
|
||||
if (!rootUrl.endsWith('/')) {
|
||||
rootUrl += '/';
|
||||
}
|
||||
|
||||
if (path) {
|
||||
while (path.startsWith('/')) path = path.slice(1);
|
||||
rootUrl += path;
|
||||
}
|
||||
|
||||
if (options.secure && /^http:/.test(rootUrl) && !/http:\/\/localhost[:/]/.test(rootUrl) && !/http:\/\/127\.0\.0\.1[:/]/.test(rootUrl)) {
|
||||
rootUrl = rootUrl.replace(/^http:/, 'https:');
|
||||
}
|
||||
|
||||
if (options.replaceLocalhost) {
|
||||
rootUrl = rootUrl.replace(/^http:\/\/localhost([:/].*)/, 'http://127.0.0.1$1');
|
||||
}
|
||||
|
||||
return rootUrl;
|
||||
}
|
||||
|
||||
absoluteUrl.defaultOptions = {
|
||||
rootUrl: baseURI,
|
||||
secure: window.isSecureContext,
|
||||
} as AbsoluteUrlOptions;
|
||||
|
||||
export function _relativeToSiteRootUrl(link: string): string {
|
||||
if (link.startsWith('/')) {
|
||||
link = getRootUrlPathPrefix() + link;
|
||||
}
|
||||
|
||||
return link;
|
||||
}
|
||||
37
apps/meteor/client/lib/actionLinks.ts
Normal file
37
apps/meteor/client/lib/actionLinks.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { IMessage } from '@rocket.chat/core-typings';
|
||||
|
||||
import { fireGlobalEvent } from './utils/fireGlobalEvent';
|
||||
import { isLayoutEmbedded } from './utils/isLayoutEmbedded';
|
||||
|
||||
// Action Links namespace creation.
|
||||
export const actionLinks = {
|
||||
actions: new Map<string, (message: IMessage, params: string) => void>(),
|
||||
register(name: string, fn: (message: IMessage, params: string) => void): void {
|
||||
actionLinks.actions.set(name, fn);
|
||||
},
|
||||
run(actionMethodId: string, message: IMessage): void {
|
||||
const embedded = isLayoutEmbedded();
|
||||
|
||||
if (embedded) {
|
||||
fireGlobalEvent('click-action-link', {
|
||||
actionlink: actionMethodId,
|
||||
value: message._id,
|
||||
message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const actionLink = message.actionLinks?.find((action) => action.method_id === actionMethodId);
|
||||
|
||||
if (!actionLink) {
|
||||
throw new Error('error-invalid-actionlink');
|
||||
}
|
||||
|
||||
if (!actionLinks.actions.has(actionLink.method_id)) {
|
||||
throw new Error('error-invalid-actionlink');
|
||||
}
|
||||
|
||||
const fn = actionLinks.actions.get(actionLink.method_id);
|
||||
fn?.(message, actionLink.params);
|
||||
},
|
||||
};
|
||||
42
apps/meteor/client/lib/appLayout.tsx
Normal file
42
apps/meteor/client/lib/appLayout.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Emitter } from '@rocket.chat/emitter';
|
||||
import type { ReactNode } from 'react';
|
||||
import { lazy } from 'react';
|
||||
|
||||
const ConnectionStatusBar = lazy(() => import('../components/connectionStatus/ConnectionStatusBar'));
|
||||
const BannerRegion = lazy(() => import('../views/banners/BannerRegion'));
|
||||
const ModalRegion = lazy(() => import('@rocket.chat/ui-client').then(({ ModalRegion }) => ({ default: ModalRegion })));
|
||||
const ActionManagerBusyState = lazy(() => import('../components/ActionManagerBusyState'));
|
||||
const AppLayoutThemeWrapper = lazy(() => import('../components/AppLayoutThemeWrapper'));
|
||||
const CloudAnnouncementsRegion = lazy(() => import('../views/cloud/CloudAnnouncementsRegion'));
|
||||
|
||||
class AppLayoutSubscription extends Emitter<{ update: void }> {
|
||||
private descriptor: ReactNode = null;
|
||||
|
||||
getSnapshot = (): ReactNode => this.descriptor;
|
||||
|
||||
subscribe = (onStoreChange: () => void): (() => void) => this.on('update', onStoreChange);
|
||||
|
||||
setCurrentValue(descriptor: ReactNode): void {
|
||||
this.descriptor = descriptor;
|
||||
this.emit('update');
|
||||
}
|
||||
|
||||
render(element: ReactNode): void {
|
||||
this.setCurrentValue(element);
|
||||
}
|
||||
|
||||
wrap(element: ReactNode): ReactNode {
|
||||
return (
|
||||
<AppLayoutThemeWrapper>
|
||||
<ConnectionStatusBar />
|
||||
<ActionManagerBusyState />
|
||||
<CloudAnnouncementsRegion />
|
||||
<BannerRegion />
|
||||
{element}
|
||||
<ModalRegion />
|
||||
</AppLayoutThemeWrapper>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const appLayout = new AppLayoutSubscription();
|
||||
3
apps/meteor/client/lib/authorization/hasPermission.ts
Normal file
3
apps/meteor/client/lib/authorization/hasPermission.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { liveAuthorizationFunctions } from './liveAuthorizationFunctions';
|
||||
|
||||
export const { hasAllPermission, hasAtLeastOnePermission, hasPermission, userHasAllPermission } = liveAuthorizationFunctions;
|
||||
3
apps/meteor/client/lib/authorization/hasRole.ts
Normal file
3
apps/meteor/client/lib/authorization/hasRole.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { liveAuthorizationFunctions } from './liveAuthorizationFunctions';
|
||||
|
||||
export const { hasRole } = liveAuthorizationFunctions;
|
||||
2
apps/meteor/client/lib/authorization/index.ts
Normal file
2
apps/meteor/client/lib/authorization/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { hasAllPermission, hasAtLeastOnePermission, hasPermission, userHasAllPermission } from './hasPermission';
|
||||
export { hasRole } from './hasRole';
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { AuthorizationDeps } from '../../../app/authorization/lib/createAuthorizationFunctions';
|
||||
import { createAuthorizationFunctions } from '../../../app/authorization/lib/createAuthorizationFunctions';
|
||||
import { PermissionsCachedStore } from '../../cachedStores';
|
||||
import { Permissions, Roles, Subscriptions, Users } from '../../stores';
|
||||
import { userIdStore } from '../user';
|
||||
|
||||
// Bind the pure factory to live zustand store accessors. Each accessor reads
|
||||
// fresh state on every call, so non-React callers (services, lib code, startup
|
||||
// scripts) keep their previous "always reflects the current store" contract
|
||||
// without going through Meteor's Tracker. React consumers should use the
|
||||
// AuthorizationContext instead, which injects React-reactive snapshots.
|
||||
const liveDeps: AuthorizationDeps = {
|
||||
getCurrentUserId: () => userIdStore.getState(),
|
||||
getUserRoles: (userId) => Users.use.getState().get(userId)?.roles,
|
||||
getPermission: (permissionId) => Permissions.use.getState().get(permissionId),
|
||||
getRoleScope: (roleId) => Roles.use.getState().get(roleId)?.scope,
|
||||
hasSubscriptionRole: (rid, roleId) =>
|
||||
Subscriptions.use
|
||||
.getState()
|
||||
.find((s) => s.rid === rid)
|
||||
?.roles?.includes(roleId) ?? false,
|
||||
isReady: () => PermissionsCachedStore.useReady.getState(),
|
||||
};
|
||||
|
||||
export const liveAuthorizationFunctions = createAuthorizationFunctions(liveDeps);
|
||||
350
apps/meteor/client/lib/autotranslate/autotranslate.spec.ts
Normal file
350
apps/meteor/client/lib/autotranslate/autotranslate.spec.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
import type { IMessage, ISupportedLanguage, ITranslatedMessage, MessageAttachmentDefault } from '@rocket.chat/core-typings';
|
||||
import mem from 'mem';
|
||||
|
||||
import { AutoTranslate, createAutoTranslateMessageStreamHandler } from './autotranslate';
|
||||
import { createFakeMessage, createFakeSubscription, createFakeUser } from '../../../tests/mocks/data';
|
||||
import { Messages, Subscriptions, Users } from '../../stores';
|
||||
import { sdk } from '../SDKClient';
|
||||
import { hasPermission } from '../authorization';
|
||||
import { settings } from '../settings';
|
||||
import { userIdStore } from '../user';
|
||||
|
||||
jest.mock('../SDKClient', () => ({
|
||||
sdk: {
|
||||
call: jest.fn(),
|
||||
rest: { get: jest.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../authorization', () => ({
|
||||
hasPermission: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../settings', () => ({
|
||||
settings: {
|
||||
peek: jest.fn(),
|
||||
observe: jest.fn(() => jest.fn()),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../../cachedStores', () => ({
|
||||
PermissionsCachedStore: {
|
||||
useReady: { subscribe: jest.fn(() => jest.fn()) },
|
||||
},
|
||||
}));
|
||||
|
||||
const USER_ID = 'userId';
|
||||
const USERNAME = 'john.doe';
|
||||
const ROOM_ID = 'roomId';
|
||||
|
||||
const asMock = (fn: unknown) => fn as jest.Mock;
|
||||
|
||||
const flushPromises = async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
};
|
||||
|
||||
const signInAs = ({ language }: { language?: string } = {}) => {
|
||||
Users.state.store(createFakeUser({ _id: USER_ID, username: USERNAME, language }));
|
||||
userIdStore.setState(USER_ID);
|
||||
};
|
||||
|
||||
describe('AutoTranslate', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
asMock(settings.observe).mockReturnValue(jest.fn());
|
||||
asMock(sdk.call).mockResolvedValue({});
|
||||
asMock(sdk.rest.get).mockResolvedValue({ languages: [] });
|
||||
|
||||
Messages.state.replaceAll([]);
|
||||
Subscriptions.state.replaceAll([]);
|
||||
Users.state.replaceAll([]);
|
||||
userIdStore.setState(undefined);
|
||||
|
||||
mem.clear(AutoTranslate.findSubscriptionByRid);
|
||||
|
||||
AutoTranslate.initialized = false;
|
||||
AutoTranslate.providersMetadata = {};
|
||||
AutoTranslate.messageIdsToWait = {};
|
||||
AutoTranslate.supportedLanguages = [];
|
||||
});
|
||||
|
||||
describe('getLanguage', () => {
|
||||
it('should prefer the language configured on the subscription', () => {
|
||||
signInAs({ language: 'en' });
|
||||
Subscriptions.state.store(createFakeSubscription({ rid: ROOM_ID, autoTranslateLanguage: 'fr' }));
|
||||
|
||||
expect(AutoTranslate.getLanguage(ROOM_ID)).toBe('fr');
|
||||
});
|
||||
|
||||
it('should fall back to the user language when the subscription has none', () => {
|
||||
signInAs({ language: 'de' });
|
||||
Subscriptions.state.store(createFakeSubscription({ rid: ROOM_ID, autoTranslateLanguage: undefined }));
|
||||
|
||||
expect(AutoTranslate.getLanguage(ROOM_ID)).toBe('de');
|
||||
});
|
||||
|
||||
it('should fall back to the user language when the room has no subscription', () => {
|
||||
signInAs({ language: 'de' });
|
||||
|
||||
expect(AutoTranslate.getLanguage(ROOM_ID)).toBe('de');
|
||||
});
|
||||
|
||||
it('should keep a regional code that is supported', () => {
|
||||
signInAs();
|
||||
AutoTranslate.supportedLanguages = [{ language: 'zh-HK', name: 'Chinese (Hong Kong)' }] as ISupportedLanguage[];
|
||||
Subscriptions.state.store(createFakeSubscription({ rid: ROOM_ID, autoTranslateLanguage: 'zh-HK' }));
|
||||
|
||||
expect(AutoTranslate.getLanguage(ROOM_ID)).toBe('zh-HK');
|
||||
});
|
||||
|
||||
it('should fall back to the base language when the regional code is not supported', () => {
|
||||
signInAs();
|
||||
AutoTranslate.supportedLanguages = [{ language: 'pt', name: 'Portuguese' }] as ISupportedLanguage[];
|
||||
Subscriptions.state.store(createFakeSubscription({ rid: ROOM_ID, autoTranslateLanguage: 'pt-BR' }));
|
||||
|
||||
expect(AutoTranslate.getLanguage(ROOM_ID)).toBe('pt');
|
||||
});
|
||||
|
||||
it('should fall back to the base language when the supported list has not loaded yet', () => {
|
||||
signInAs();
|
||||
AutoTranslate.supportedLanguages = undefined;
|
||||
Subscriptions.state.store(createFakeSubscription({ rid: ROOM_ID, autoTranslateLanguage: 'pt-BR' }));
|
||||
|
||||
expect(AutoTranslate.getLanguage(ROOM_ID)).toBe('pt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('translateAttachments', () => {
|
||||
beforeEach(() => {
|
||||
signInAs();
|
||||
});
|
||||
|
||||
it('should return attachments untouched when they are not translatable', () => {
|
||||
const attachments = [{ text: 'hello' }] as MessageAttachmentDefault[];
|
||||
|
||||
expect(AutoTranslate.translateAttachments(attachments, 'fr', false)).toBe(attachments);
|
||||
expect(attachments[0].text).toBe('hello');
|
||||
});
|
||||
|
||||
it('should replace the text with the translation and keep the original', () => {
|
||||
const attachments = [
|
||||
{ author_name: 'someone', text: 'hello', translations: { fr: 'bonjour' } },
|
||||
] as unknown as MessageAttachmentDefault[];
|
||||
|
||||
AutoTranslate.translateAttachments(attachments, 'fr', false);
|
||||
|
||||
expect(attachments[0].text).toBe('bonjour');
|
||||
expect((attachments[0] as any).translations.original).toBe('hello');
|
||||
});
|
||||
|
||||
it('should keep the original text when showing the inverse translation', () => {
|
||||
const attachments = [
|
||||
{ author_name: 'someone', text: 'hello', translations: { fr: 'bonjour' } },
|
||||
] as unknown as MessageAttachmentDefault[];
|
||||
|
||||
AutoTranslate.translateAttachments(attachments, 'fr', true);
|
||||
|
||||
expect(attachments[0].text).toBe('hello');
|
||||
});
|
||||
|
||||
it('should replace the description with the translation for the given language', () => {
|
||||
const attachments = [
|
||||
{ author_name: 'someone', description: 'a picture', translations: { fr: 'une image' } },
|
||||
] as unknown as MessageAttachmentDefault[];
|
||||
|
||||
AutoTranslate.translateAttachments(attachments, 'fr', false);
|
||||
|
||||
expect(attachments[0].description).toBe('une image');
|
||||
});
|
||||
|
||||
it('should keep the original description when showing the inverse translation', () => {
|
||||
const attachments = [
|
||||
{ author_name: 'someone', description: 'a picture', translations: { fr: 'une image' } },
|
||||
] as unknown as MessageAttachmentDefault[];
|
||||
|
||||
AutoTranslate.translateAttachments(attachments, 'fr', true);
|
||||
|
||||
expect(attachments[0].description).toBe('a picture');
|
||||
});
|
||||
|
||||
it('should not translate attachments authored by the current user', () => {
|
||||
const attachments = [
|
||||
{ author_name: USERNAME, text: 'hello', translations: { fr: 'bonjour' } },
|
||||
] as unknown as MessageAttachmentDefault[];
|
||||
|
||||
AutoTranslate.translateAttachments(attachments, 'fr', false);
|
||||
|
||||
expect(attachments[0].text).toBe('hello');
|
||||
});
|
||||
|
||||
it('should leave the text alone when there is no translation for the language', () => {
|
||||
const attachments = [
|
||||
{ author_name: 'someone', text: 'hello', translations: { de: 'hallo' } },
|
||||
] as unknown as MessageAttachmentDefault[];
|
||||
|
||||
AutoTranslate.translateAttachments(attachments, 'fr', false);
|
||||
|
||||
expect(attachments[0].text).toBe('hello');
|
||||
});
|
||||
|
||||
it('should translate nested attachments', () => {
|
||||
const attachments = [
|
||||
{
|
||||
author_name: 'someone',
|
||||
text: 'outer',
|
||||
translations: { fr: 'exterieur' },
|
||||
attachments: [{ author_name: 'someone', text: 'inner', translations: { fr: 'interieur' } }],
|
||||
},
|
||||
] as unknown as MessageAttachmentDefault[];
|
||||
|
||||
AutoTranslate.translateAttachments(attachments, 'fr', false);
|
||||
|
||||
expect((attachments[0] as any).attachments[0].text).toBe('interieur');
|
||||
});
|
||||
});
|
||||
|
||||
describe('init', () => {
|
||||
const enableAutoTranslate = () => {
|
||||
asMock(settings.peek).mockReturnValue(true);
|
||||
asMock(hasPermission).mockReturnValue(true);
|
||||
userIdStore.setState(USER_ID);
|
||||
};
|
||||
|
||||
it('should load provider metadata and supported languages when enabled, signed in and permitted', async () => {
|
||||
enableAutoTranslate();
|
||||
asMock(sdk.call).mockResolvedValue({ google: { name: 'google', displayName: 'Google' } });
|
||||
asMock(sdk.rest.get).mockResolvedValue({ languages: [{ language: 'fr', name: 'French' }] });
|
||||
|
||||
AutoTranslate.init();
|
||||
await flushPromises();
|
||||
|
||||
expect(AutoTranslate.providersMetadata).toEqual({ google: { name: 'google', displayName: 'Google' } });
|
||||
expect(AutoTranslate.supportedLanguages).toEqual([{ language: 'fr', name: 'French' }]);
|
||||
expect(AutoTranslate.initialized).toBe(true);
|
||||
});
|
||||
|
||||
it('should not subscribe again when already initialized', () => {
|
||||
AutoTranslate.initialized = true;
|
||||
|
||||
AutoTranslate.init();
|
||||
|
||||
expect(settings.observe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not request providers when the setting is disabled', () => {
|
||||
asMock(settings.peek).mockReturnValue(false);
|
||||
asMock(hasPermission).mockReturnValue(true);
|
||||
userIdStore.setState(USER_ID);
|
||||
|
||||
AutoTranslate.init();
|
||||
|
||||
expect(sdk.call).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not request providers when there is no signed-in user', () => {
|
||||
asMock(settings.peek).mockReturnValue(true);
|
||||
asMock(hasPermission).mockReturnValue(true);
|
||||
userIdStore.setState(undefined);
|
||||
|
||||
AutoTranslate.init();
|
||||
|
||||
expect(sdk.call).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not request providers when the user lacks the auto-translate permission', () => {
|
||||
asMock(settings.peek).mockReturnValue(true);
|
||||
asMock(hasPermission).mockReturnValue(false);
|
||||
userIdStore.setState(USER_ID);
|
||||
|
||||
AutoTranslate.init();
|
||||
|
||||
expect(sdk.call).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not throw and should keep defaults when loading providers fails', async () => {
|
||||
enableAutoTranslate();
|
||||
asMock(sdk.call).mockRejectedValue(new Error('autotranslate disabled'));
|
||||
asMock(sdk.rest.get).mockRejectedValue(new Error('autotranslate disabled'));
|
||||
const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
|
||||
AutoTranslate.init();
|
||||
await flushPromises();
|
||||
|
||||
expect(AutoTranslate.providersMetadata).toEqual({});
|
||||
expect(consoleError).toHaveBeenCalledWith('autotranslate disabled');
|
||||
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createAutoTranslateMessageStreamHandler', () => {
|
||||
beforeEach(() => {
|
||||
signInAs();
|
||||
asMock(settings.peek).mockReturnValue(false);
|
||||
asMock(hasPermission).mockReturnValue(false);
|
||||
userIdStore.setState(USER_ID);
|
||||
});
|
||||
|
||||
const storeMessage = (overrides: Partial<ITranslatedMessage> = {}) => {
|
||||
const message = {
|
||||
...createFakeMessage<IMessage>({ rid: ROOM_ID, msg: 'hello', u: { _id: 'anotherUserId', username: 'someone' } }),
|
||||
...overrides,
|
||||
} as ITranslatedMessage;
|
||||
Messages.state.store(message);
|
||||
return message;
|
||||
};
|
||||
|
||||
it('should ignore messages authored by the current user', () => {
|
||||
const handler = createAutoTranslateMessageStreamHandler();
|
||||
Subscriptions.state.store(createFakeSubscription({ rid: ROOM_ID, autoTranslate: true }));
|
||||
const message = storeMessage({ u: { _id: USER_ID, username: USERNAME } });
|
||||
|
||||
handler(message);
|
||||
|
||||
expect(Messages.state.get(message._id)?.autoTranslateFetching).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should flag an untranslated message as fetching when the subscription auto-translates', () => {
|
||||
Subscriptions.state.store(createFakeSubscription({ rid: ROOM_ID, autoTranslate: true, autoTranslateLanguage: 'fr' }));
|
||||
const handler = createAutoTranslateMessageStreamHandler();
|
||||
const message = storeMessage();
|
||||
|
||||
handler(message);
|
||||
|
||||
expect(Messages.state.get(message._id)?.autoTranslateFetching).toBe(true);
|
||||
});
|
||||
|
||||
it('should not flag a message that already carries the translation', () => {
|
||||
Subscriptions.state.store(createFakeSubscription({ rid: ROOM_ID, autoTranslate: true, autoTranslateLanguage: 'fr' }));
|
||||
const handler = createAutoTranslateMessageStreamHandler();
|
||||
const message = storeMessage({ translations: { fr: 'bonjour' } });
|
||||
|
||||
handler(message);
|
||||
|
||||
expect(Messages.state.get(message._id)?.autoTranslateFetching).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should clear the fetching flag when auto-translate was turned off while waiting', () => {
|
||||
Subscriptions.state.store(createFakeSubscription({ rid: ROOM_ID, autoTranslate: false }));
|
||||
const handler = createAutoTranslateMessageStreamHandler();
|
||||
const message = storeMessage({ autoTranslateFetching: true });
|
||||
AutoTranslate.messageIdsToWait[message._id] = true;
|
||||
|
||||
handler(message);
|
||||
|
||||
expect(Messages.state.get(message._id)?.autoTranslateFetching).toBeUndefined();
|
||||
expect(AutoTranslate.messageIdsToWait[message._id]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should clear a stale fetching flag when the room has no auto-translating subscription', () => {
|
||||
const handler = createAutoTranslateMessageStreamHandler();
|
||||
const message = storeMessage({ autoTranslateFetching: true });
|
||||
|
||||
handler(message);
|
||||
|
||||
expect(Messages.state.get(message._id)?.autoTranslateFetching).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
170
apps/meteor/client/lib/autotranslate/autotranslate.ts
Normal file
170
apps/meteor/client/lib/autotranslate/autotranslate.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import type { IRoom, ISubscription, ISupportedLanguage, ITranslatedMessage, MessageAttachmentDefault } from '@rocket.chat/core-typings';
|
||||
import { isTranslatedMessageAttachment } from '@rocket.chat/core-typings';
|
||||
import mem from 'mem';
|
||||
|
||||
import { PermissionsCachedStore } from '../../cachedStores';
|
||||
import { Messages, Subscriptions, Users } from '../../stores';
|
||||
import { hasTranslationLanguageInAttachments, hasTranslationLanguageInMessage } from '../../views/room/MessageList/lib/autoTranslate';
|
||||
import { sdk } from '../SDKClient';
|
||||
import { hasPermission } from '../authorization';
|
||||
import { settings } from '../settings';
|
||||
import { getUserId, userIdStore } from '../user';
|
||||
|
||||
let userLanguage = 'en';
|
||||
let username = '';
|
||||
|
||||
const refreshUserCache = () => {
|
||||
const uid = userIdStore.getState();
|
||||
const user = uid ? Users.use.getState().get(uid) : undefined;
|
||||
if (!user) return;
|
||||
userLanguage = user.language || 'en';
|
||||
username = user.username || '';
|
||||
};
|
||||
refreshUserCache();
|
||||
userIdStore.subscribe(refreshUserCache);
|
||||
Users.use.subscribe(refreshUserCache);
|
||||
|
||||
export const AutoTranslate = {
|
||||
initialized: false,
|
||||
providersMetadata: {} as { [providerNamer: string]: { name: string; displayName: string } },
|
||||
messageIdsToWait: {} as { [messageId: string]: boolean },
|
||||
supportedLanguages: [] as ISupportedLanguage[] | undefined,
|
||||
|
||||
findSubscriptionByRid: mem((rid) => Subscriptions.state.find((record) => record.rid === rid)),
|
||||
|
||||
getLanguage(rid: IRoom['_id']): string {
|
||||
let subscription: ISubscription | undefined;
|
||||
if (rid) {
|
||||
subscription = this.findSubscriptionByRid(rid);
|
||||
}
|
||||
const language = (subscription?.autoTranslateLanguage || userLanguage || window.defaultUserLanguage?.()) as string;
|
||||
if (language.indexOf('-') !== -1) {
|
||||
if (!(this.supportedLanguages || []).some((supportedLanguage) => supportedLanguage.language === language)) {
|
||||
return language.slice(0, 2);
|
||||
}
|
||||
}
|
||||
return language;
|
||||
},
|
||||
|
||||
translateAttachments(
|
||||
attachments: MessageAttachmentDefault[],
|
||||
language: string,
|
||||
autoTranslateShowInverse: boolean,
|
||||
): MessageAttachmentDefault[] {
|
||||
if (!isTranslatedMessageAttachment(attachments)) {
|
||||
return attachments;
|
||||
}
|
||||
for (const attachment of attachments) {
|
||||
if (attachment.author_name !== username) {
|
||||
if (attachment.text && attachment.translations && attachment.translations[language]) {
|
||||
attachment.translations.original = attachment.text;
|
||||
|
||||
if (autoTranslateShowInverse) {
|
||||
attachment.text = attachment.translations.original;
|
||||
} else {
|
||||
attachment.text = attachment.translations[language];
|
||||
}
|
||||
}
|
||||
|
||||
if (attachment.description && attachment.translations && attachment.translations[language]) {
|
||||
attachment.translations.original = attachment.description;
|
||||
|
||||
if (autoTranslateShowInverse) {
|
||||
attachment.description = attachment.translations.original;
|
||||
} else {
|
||||
attachment.description = attachment.translations[language];
|
||||
}
|
||||
}
|
||||
|
||||
if (attachment.attachments && attachment.attachments.length > 0) {
|
||||
// @ts-expect-error - not sure what to do with this
|
||||
attachment.attachments = this.translateAttachments(attachment.attachments, language);
|
||||
}
|
||||
}
|
||||
}
|
||||
return attachments;
|
||||
},
|
||||
|
||||
init(): void {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loadProviders = async () => {
|
||||
try {
|
||||
const [providersMetadata, supportedLanguagesResponse] = await Promise.all([
|
||||
sdk.call('autoTranslate.getProviderUiMetadata'),
|
||||
sdk.rest.get('/v1/autotranslate.getSupportedLanguages', { targetLanguage: 'en' }),
|
||||
]);
|
||||
this.providersMetadata = providersMetadata;
|
||||
this.supportedLanguages = supportedLanguagesResponse.languages;
|
||||
} catch (e: unknown) {
|
||||
// Avoid unwanted error message on UI when autotranslate is disabled while fetching data
|
||||
console.error((e as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
let loaded = false;
|
||||
const unsubs: Array<() => void> = [];
|
||||
const tryLoad = async () => {
|
||||
if (loaded) return;
|
||||
if (!settings.peek('AutoTranslate_Enabled') || !userIdStore.getState() || !hasPermission('auto-translate')) {
|
||||
return;
|
||||
}
|
||||
loaded = true;
|
||||
unsubs.splice(0).forEach((unsubscribe) => unsubscribe());
|
||||
await loadProviders();
|
||||
};
|
||||
|
||||
unsubs.push(userIdStore.subscribe(() => void tryLoad()));
|
||||
unsubs.push(settings.observe('AutoTranslate_Enabled', () => void tryLoad()));
|
||||
unsubs.push(PermissionsCachedStore.useReady.subscribe(() => void tryLoad()));
|
||||
|
||||
void tryLoad();
|
||||
|
||||
Subscriptions.use.subscribe(() => {
|
||||
mem.clear(this.findSubscriptionByRid);
|
||||
});
|
||||
|
||||
this.initialized = true;
|
||||
},
|
||||
};
|
||||
|
||||
export const createAutoTranslateMessageStreamHandler = (): ((message: ITranslatedMessage) => void) => {
|
||||
AutoTranslate.init();
|
||||
|
||||
return (message: ITranslatedMessage): void => {
|
||||
if (message.u && message.u._id !== getUserId()) {
|
||||
const subscription = AutoTranslate.findSubscriptionByRid(message.rid);
|
||||
const language = AutoTranslate.getLanguage(message.rid);
|
||||
if (
|
||||
subscription &&
|
||||
subscription.autoTranslate === true &&
|
||||
message.msg &&
|
||||
(!message.translations ||
|
||||
(!hasTranslationLanguageInMessage(message, language) && !hasTranslationLanguageInAttachments(message.attachments, language)))
|
||||
) {
|
||||
Messages.state.update(
|
||||
(record) => record._id === message._id,
|
||||
(record) => ({
|
||||
...record,
|
||||
autoTranslateFetching: true,
|
||||
}),
|
||||
);
|
||||
} else if (AutoTranslate.messageIdsToWait[message._id] !== undefined && subscription && subscription.autoTranslate !== true) {
|
||||
Messages.state.update(
|
||||
(record) => record._id === message._id,
|
||||
({ autoTranslateFetching: _, ...record }) => ({
|
||||
...record,
|
||||
}),
|
||||
);
|
||||
delete AutoTranslate.messageIdsToWait[message._id];
|
||||
} else if (message.autoTranslateFetching === true) {
|
||||
Messages.state.update(
|
||||
(record) => record._id === message._id,
|
||||
({ autoTranslateFetching: _, ...record }) => record,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
1
apps/meteor/client/lib/autotranslate/index.ts
Normal file
1
apps/meteor/client/lib/autotranslate/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { AutoTranslate, createAutoTranslateMessageStreamHandler } from './autotranslate';
|
||||
86
apps/meteor/client/lib/banners.ts
Normal file
86
apps/meteor/client/lib/banners.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { Emitter } from '@rocket.chat/emitter';
|
||||
import type { Keys as IconName } from '@rocket.chat/icons';
|
||||
import type * as UiKit from '@rocket.chat/ui-kit';
|
||||
|
||||
export type LegacyBannerPayload = {
|
||||
id: string;
|
||||
closable?: boolean;
|
||||
title?: string | (() => string);
|
||||
text?: string | (() => string);
|
||||
html?: string | (() => string);
|
||||
icon?: IconName;
|
||||
modifiers?: ('large' | 'danger')[];
|
||||
timer?: number;
|
||||
action?: () => Promise<void> | void;
|
||||
onClose?: () => Promise<void> | void;
|
||||
};
|
||||
|
||||
type BannerPayload = LegacyBannerPayload | UiKit.BannerView;
|
||||
|
||||
export const isLegacyPayload = (payload: BannerPayload): payload is LegacyBannerPayload => !('blocks' in payload);
|
||||
|
||||
const queue: BannerPayload[] = [];
|
||||
const emitter = new Emitter<{
|
||||
'update': undefined;
|
||||
'update-first': undefined;
|
||||
}>();
|
||||
|
||||
export const firstSubscription = [
|
||||
(callback: () => void): (() => void) => emitter.on('update-first', callback),
|
||||
(): BannerPayload | null => queue[0] ?? null,
|
||||
] as const;
|
||||
|
||||
export const open = (payload: BannerPayload): void => {
|
||||
let index = queue.findIndex((_payload) => {
|
||||
if (isLegacyPayload(_payload) && isLegacyPayload(payload)) {
|
||||
return _payload.id === payload.id;
|
||||
}
|
||||
|
||||
if (!isLegacyPayload(_payload) && !isLegacyPayload(payload)) {
|
||||
return _payload.viewId === payload.viewId;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
if (index === -1) {
|
||||
index = queue.length;
|
||||
}
|
||||
|
||||
queue[index] = payload;
|
||||
|
||||
emitter.emit('update');
|
||||
|
||||
if (index === 0) {
|
||||
emitter.emit('update-first');
|
||||
}
|
||||
};
|
||||
|
||||
export const closeById = (id: string): void => {
|
||||
const index = queue.findIndex((banner) => {
|
||||
if (!isLegacyPayload(banner)) {
|
||||
return banner.viewId === id;
|
||||
}
|
||||
return banner.id === id;
|
||||
});
|
||||
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
queue.splice(index, 1);
|
||||
emitter.emit('update');
|
||||
index === 0 && emitter.emit('update-first');
|
||||
};
|
||||
|
||||
export const close = (): void => {
|
||||
queue.shift();
|
||||
emitter.emit('update');
|
||||
emitter.emit('update-first');
|
||||
};
|
||||
|
||||
export const clear = (): void => {
|
||||
queue.splice(0, queue.length);
|
||||
emitter.emit('update');
|
||||
emitter.emit('update-first');
|
||||
};
|
||||
18
apps/meteor/client/lib/baseURI.ts
Normal file
18
apps/meteor/client/lib/baseURI.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export const baseURI: string = ((): string => {
|
||||
if (document.baseURI) {
|
||||
return document.baseURI;
|
||||
}
|
||||
|
||||
// Should be exactly one tag:
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
|
||||
const base = document.getElementsByTagName('base');
|
||||
|
||||
// Return location from BASE tag.
|
||||
if (base.length > 0) {
|
||||
return base[0].href;
|
||||
}
|
||||
|
||||
// Else use implementation of documentURI:
|
||||
// http://www.w3.org/TR/DOM-Level-3-Core/core.html#Node3-baseURI
|
||||
return document.URL;
|
||||
})();
|
||||
12
apps/meteor/client/lib/buildAuthDeeplinkURL.ts
Normal file
12
apps/meteor/client/lib/buildAuthDeeplinkURL.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export const buildDeepLinkURL = (resumeToken: string, userId: string) => {
|
||||
const url = new URL(window.location.href);
|
||||
const { origin } = url;
|
||||
return `rocketchat://auth?host=${origin}&token=${resumeToken}&userId=${userId}`;
|
||||
};
|
||||
|
||||
export const buildSamlDeepLinkURL = (credentialToken: string) => {
|
||||
const { origin } = new URL(window.location.href);
|
||||
const params = new URLSearchParams({ type: 'saml', host: origin, credentialToken });
|
||||
|
||||
return `rocketchat://auth?${params.toString()}`;
|
||||
};
|
||||
403
apps/meteor/client/lib/cachedStores/CachedStore.ts
Normal file
403
apps/meteor/client/lib/cachedStores/CachedStore.ts
Normal file
@@ -0,0 +1,403 @@
|
||||
import type { IRocketChatRecord } from '@rocket.chat/core-typings';
|
||||
import type { StreamNames } from '@rocket.chat/ddp-client';
|
||||
import { isTruthy } from '@rocket.chat/tools';
|
||||
import localforage from 'localforage';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { create, type StoreApi, type UseBoundStore } from 'zustand';
|
||||
|
||||
import { baseURI } from '../baseURI';
|
||||
import { onLoggedIn } from '../loggedIn';
|
||||
import { CachedStoresManager } from './CachedStoresManager';
|
||||
import type { IDocumentMapStore } from './DocumentMapStore';
|
||||
import { withDebouncing } from '../../../lib/utils/highOrderFunctions';
|
||||
import { sdk } from '../SDKClient';
|
||||
import { getDdpSdk } from '../sdk/ddpSdk';
|
||||
import { STORAGE_KEYS, getStoredItem } from '../sdk/storage';
|
||||
import { getUserId } from '../user';
|
||||
import { getConfig } from '../utils/getConfig';
|
||||
|
||||
type Name = 'rooms' | 'subscriptions' | 'permissions' | 'public-settings' | 'private-settings';
|
||||
|
||||
const hasId = <T>(record: T): record is T & { _id: string } => typeof record === 'object' && record !== null && '_id' in record;
|
||||
const hasUpdatedAt = <T>(record: T): record is T & { _updatedAt: Date } =>
|
||||
typeof record === 'object' &&
|
||||
record !== null &&
|
||||
'_updatedAt' in record &&
|
||||
(record as unknown as { _updatedAt: unknown })._updatedAt instanceof Date;
|
||||
const hasDeletedAt = <T>(record: T): record is T & { _deletedAt: Date } =>
|
||||
typeof record === 'object' &&
|
||||
record !== null &&
|
||||
'_deletedAt' in record &&
|
||||
(record as unknown as { _deletedAt: unknown })._deletedAt instanceof Date;
|
||||
const hasUnserializedUpdatedAt = <T>(record: T): record is T & { _updatedAt: ConstructorParameters<typeof Date>[0] } =>
|
||||
typeof record === 'object' &&
|
||||
record !== null &&
|
||||
'_updatedAt' in record &&
|
||||
!((record as unknown as { _updatedAt: unknown })._updatedAt instanceof Date);
|
||||
|
||||
localforage.config({ name: baseURI });
|
||||
|
||||
export interface IWithManageableCache {
|
||||
clearCacheOnLogout(): void;
|
||||
}
|
||||
|
||||
export abstract class CachedStore<T extends IRocketChatRecord, U = T> implements IWithManageableCache {
|
||||
private static readonly MAX_CACHE_TIME = 60 * 60 * 24 * 30;
|
||||
|
||||
readonly store: UseBoundStore<StoreApi<IDocumentMapStore<T>>>;
|
||||
|
||||
protected name: Name;
|
||||
|
||||
protected eventType: StreamNames;
|
||||
|
||||
// Bumped from 18 → 19 to invalidate caches populated before the DDPSDK
|
||||
// wire encoding was switched from JSON to EJSON. Entries written by the
|
||||
// JSON window stored dates as ISO strings instead of Date instances, so
|
||||
// fields like subscription.ls would fail `.getTime()` when read back.
|
||||
private readonly version = 19;
|
||||
|
||||
private updatedAt = new Date(0);
|
||||
|
||||
protected log: (...args: any[]) => void;
|
||||
|
||||
private timer: ReturnType<typeof setTimeout>;
|
||||
|
||||
readonly useReady = create(() => false);
|
||||
|
||||
constructor({ name, eventType, store }: { name: Name; eventType: StreamNames; store: UseBoundStore<StoreApi<IDocumentMapStore<T>>> }) {
|
||||
this.name = name;
|
||||
this.eventType = eventType;
|
||||
this.store = store;
|
||||
|
||||
this.log = [getConfig(`debugCachedCollection-${this.name}`), getConfig('debugCachedCollection'), getConfig('debug')].includes('true')
|
||||
? console.log.bind(console, `%cCachedCollection ${this.name}`, `color: navy; font-weight: bold;`)
|
||||
: () => undefined;
|
||||
|
||||
CachedStoresManager.register(this);
|
||||
}
|
||||
|
||||
protected get eventName(): `${Name}-changed` | `${string}/${Name}-changed` {
|
||||
if (this.eventType === 'notify-user') {
|
||||
return `${getUserId()}/${this.name}-changed`;
|
||||
}
|
||||
return `${this.name}-changed`;
|
||||
}
|
||||
|
||||
protected abstract getToken(): unknown;
|
||||
|
||||
private async loadFromCache() {
|
||||
const data = await localforage.getItem<{ version: number; token: unknown; records: unknown[]; updatedAt: Date | string }>(this.name);
|
||||
|
||||
if (!data) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (data.version < this.version || data.token !== this.getToken()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (data.records.length <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// updatedAt may be a Date or a string depending on the used localForage backend
|
||||
if (!(data.updatedAt instanceof Date)) {
|
||||
data.updatedAt = new Date(data.updatedAt);
|
||||
}
|
||||
|
||||
if (Date.now() - data.updatedAt.getTime() >= 1000 * CachedStore.MAX_CACHE_TIME) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.log(`${data.records.length} records loaded from cache`);
|
||||
|
||||
const deserializedRecords = data.records.map((record) => this.deserializeFromCache(record)).filter(isTruthy);
|
||||
|
||||
const updatedAt = Math.max(...deserializedRecords.filter(hasUpdatedAt).map((record) => record?._updatedAt.getTime() ?? 0));
|
||||
|
||||
if (updatedAt > this.updatedAt.getTime()) {
|
||||
this.updatedAt = new Date(updatedAt);
|
||||
}
|
||||
|
||||
this.store.getState().replaceAll(deserializedRecords.filter(hasId));
|
||||
|
||||
this.updatedAt = data.updatedAt || this.updatedAt;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected deserializeFromCache(record: unknown): T | undefined {
|
||||
if (typeof record !== 'object' || record === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
...(record as unknown as T),
|
||||
...(hasUnserializedUpdatedAt(record) && {
|
||||
_updatedAt: new Date(record._updatedAt),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
private async callLoad() {
|
||||
// TODO: workaround for bad function overload
|
||||
const data = await sdk.call(`${this.name}/get`);
|
||||
return data as unknown as U[];
|
||||
}
|
||||
|
||||
private async callSync(updatedSince: Date) {
|
||||
// TODO: workaround for bad function overload
|
||||
const data = await sdk.call(`${this.name}/get`, updatedSince);
|
||||
return data as unknown as { update: U[]; remove: U[] };
|
||||
}
|
||||
|
||||
private async loadFromServer() {
|
||||
const startTime = new Date();
|
||||
const lastTime = this.updatedAt;
|
||||
const data = await this.callLoad();
|
||||
this.log(`${data.length} records loaded from server`);
|
||||
|
||||
const newRecords = data.map((record) => {
|
||||
const mapped = this.mapRecord(record);
|
||||
|
||||
if (hasUpdatedAt(mapped) && mapped._updatedAt > this.updatedAt) {
|
||||
this.updatedAt = mapped._updatedAt;
|
||||
}
|
||||
|
||||
return mapped;
|
||||
});
|
||||
|
||||
this.store.getState().storeMany(newRecords);
|
||||
this.handleLoadedFromServer(newRecords);
|
||||
|
||||
this.updatedAt = this.updatedAt === lastTime ? startTime : this.updatedAt;
|
||||
}
|
||||
|
||||
protected mapRecord(record: U): T {
|
||||
return record as unknown as T;
|
||||
}
|
||||
|
||||
protected handleLoadedFromServer(_records: T[]): void {
|
||||
// This method can be overridden to handle records after they are loaded from the server
|
||||
}
|
||||
|
||||
protected handleSyncEvent(_action: 'removed' | 'changed', _record: T): void {
|
||||
// This method can be overridden to handle sync events
|
||||
}
|
||||
|
||||
private async loadFromServerAndPopulate() {
|
||||
await this.loadFromServer();
|
||||
await this.save();
|
||||
}
|
||||
|
||||
private save = withDebouncing({ wait: 1000 })(async () => {
|
||||
this.log('saving cache');
|
||||
await localforage.setItem(this.name, {
|
||||
updatedAt: this.updatedAt,
|
||||
version: this.version,
|
||||
token: this.getToken(),
|
||||
records: Array.from(this.store.getState().records.values()),
|
||||
});
|
||||
this.log('saving cache (done)');
|
||||
});
|
||||
|
||||
abstract clearCacheOnLogout(): void;
|
||||
|
||||
protected async clearCache() {
|
||||
this.log('clearing cache');
|
||||
await localforage.removeItem(this.name);
|
||||
this.store.getState().replaceAll([]);
|
||||
}
|
||||
|
||||
protected setupListener() {
|
||||
return sdk.stream(this.eventType, [this.eventName], (async (action: 'removed' | 'changed', record: U) => {
|
||||
this.log('record received', action, record);
|
||||
await this.handleRecordEvent(action, record);
|
||||
}) as (...args: unknown[]) => void);
|
||||
}
|
||||
|
||||
protected async handleRecordEvent(action: 'removed' | 'changed', record: U) {
|
||||
const newRecord = this.mapRecord(record);
|
||||
|
||||
if (action === 'removed') {
|
||||
this.store.getState().delete(newRecord._id);
|
||||
} else {
|
||||
this.store.getState().store(newRecord);
|
||||
}
|
||||
|
||||
await this.save();
|
||||
}
|
||||
|
||||
private trySync(delay = 10) {
|
||||
clearTimeout(this.timer);
|
||||
// Wait for an empty queue to load data again and sync
|
||||
this.timer = setTimeout(async () => {
|
||||
if (!(await this.sync())) {
|
||||
return this.trySync(delay);
|
||||
}
|
||||
await this.save();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
protected async sync() {
|
||||
if (!this.updatedAt || this.updatedAt.getTime() === 0 || Meteor.connection._outstandingMethodBlocks.length !== 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const startTime = new Date();
|
||||
const lastTime = this.updatedAt;
|
||||
|
||||
this.log(`syncing from ${this.updatedAt}`);
|
||||
|
||||
const data = await this.callSync(this.updatedAt);
|
||||
const changes = [];
|
||||
|
||||
if (data.update && data.update.length > 0) {
|
||||
this.log(`${data.update.length} records updated in sync`);
|
||||
for (const record of data.update) {
|
||||
const newRecord = this.mapRecord(record);
|
||||
|
||||
const actionTime = hasUpdatedAt(newRecord) ? newRecord._updatedAt : startTime;
|
||||
changes.push({
|
||||
action: () => {
|
||||
this.store.getState().store(newRecord);
|
||||
if (actionTime > this.updatedAt) {
|
||||
this.updatedAt = actionTime;
|
||||
}
|
||||
this.handleSyncEvent('changed', newRecord);
|
||||
},
|
||||
timestamp: actionTime.getTime(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (data.remove && data.remove.length > 0) {
|
||||
this.log(`${data.remove.length} records removed in sync`);
|
||||
for (const record of data.remove) {
|
||||
const newRecord = this.mapRecord(record);
|
||||
|
||||
if (!hasDeletedAt(newRecord)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const actionTime = newRecord._deletedAt;
|
||||
changes.push({
|
||||
action: () => {
|
||||
this.store.getState().delete(newRecord._id);
|
||||
if (actionTime > this.updatedAt) {
|
||||
this.updatedAt = actionTime;
|
||||
}
|
||||
this.handleSyncEvent('removed', newRecord);
|
||||
},
|
||||
timestamp: actionTime.getTime(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
changes
|
||||
.sort((a, b) => a.timestamp - b.timestamp)
|
||||
.forEach((c) => {
|
||||
c.action();
|
||||
});
|
||||
|
||||
this.updatedAt = this.updatedAt === lastTime ? startTime : this.updatedAt;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private listenerUnsubscriber: (() => void) | undefined;
|
||||
|
||||
private async performInitialization() {
|
||||
if (await this.loadFromCache()) {
|
||||
this.trySync();
|
||||
} else {
|
||||
await this.loadFromServerAndPopulate();
|
||||
}
|
||||
|
||||
this.reconnectionUnsubscribe?.();
|
||||
const sdk = getDdpSdk();
|
||||
let wentOffline = sdk.connection.status !== 'connected';
|
||||
this.reconnectionUnsubscribe = sdk.connection.on('connection', () => {
|
||||
if (sdk.connection.status !== 'connected') {
|
||||
wentOffline = true;
|
||||
return;
|
||||
}
|
||||
if (wentOffline) {
|
||||
wentOffline = false;
|
||||
this.trySync();
|
||||
}
|
||||
});
|
||||
|
||||
const subscription = this.setupListener();
|
||||
this.listenerUnsubscriber = () => {
|
||||
subscription.stop();
|
||||
this.listenerUnsubscriber = undefined;
|
||||
};
|
||||
}
|
||||
|
||||
private initializationPromise: Promise<void> | undefined;
|
||||
|
||||
init() {
|
||||
if (this.initializationPromise) {
|
||||
return this.initializationPromise;
|
||||
}
|
||||
|
||||
this.initializationPromise = this.performInitialization()
|
||||
.catch(console.error)
|
||||
.finally(() => {
|
||||
this.initializationPromise = undefined;
|
||||
this.setReady(true);
|
||||
});
|
||||
|
||||
return this.initializationPromise;
|
||||
}
|
||||
|
||||
async release() {
|
||||
if (this.initializationPromise) {
|
||||
await this.initializationPromise;
|
||||
}
|
||||
|
||||
this.listenerUnsubscriber?.();
|
||||
this.setReady(false);
|
||||
}
|
||||
|
||||
private reconnectionUnsubscribe: (() => void) | undefined;
|
||||
|
||||
setReady(ready: boolean) {
|
||||
this.useReady.setState(ready);
|
||||
}
|
||||
}
|
||||
|
||||
export class PublicCachedStore<T extends IRocketChatRecord, U = T> extends CachedStore<T, U> {
|
||||
protected override getToken() {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
override clearCacheOnLogout() {
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
export class PrivateCachedStore<T extends IRocketChatRecord, U = T> extends CachedStore<T, U> {
|
||||
protected override getToken() {
|
||||
return getStoredItem(STORAGE_KEYS.LOGIN_TOKEN);
|
||||
}
|
||||
|
||||
override clearCacheOnLogout() {
|
||||
void this.clearCache();
|
||||
}
|
||||
|
||||
listen() {
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
return;
|
||||
}
|
||||
|
||||
onLoggedIn(() => {
|
||||
void this.init();
|
||||
});
|
||||
|
||||
getDdpSdk().account.onLogout(() => {
|
||||
this.release();
|
||||
});
|
||||
}
|
||||
}
|
||||
25
apps/meteor/client/lib/cachedStores/CachedStoresManager.ts
Normal file
25
apps/meteor/client/lib/cachedStores/CachedStoresManager.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { IWithManageableCache } from './CachedStore';
|
||||
import { getDdpSdk } from '../sdk/ddpSdk';
|
||||
|
||||
class CachedStoresManager {
|
||||
private items = new Set<IWithManageableCache>();
|
||||
|
||||
register(cachedCollection: IWithManageableCache) {
|
||||
this.items.add(cachedCollection);
|
||||
}
|
||||
|
||||
clearAllCachesOnLogout() {
|
||||
for (const item of this.items) {
|
||||
item.clearCacheOnLogout();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const instance = new CachedStoresManager();
|
||||
|
||||
getDdpSdk().account.onLogout(() => instance.clearAllCachesOnLogout());
|
||||
|
||||
export {
|
||||
/** @deprecated */
|
||||
instance as CachedStoresManager,
|
||||
};
|
||||
308
apps/meteor/client/lib/cachedStores/DocumentMapStore.ts
Normal file
308
apps/meteor/client/lib/cachedStores/DocumentMapStore.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
export interface IDocumentMapStore<T extends { _id: string }> {
|
||||
readonly records: ReadonlyMap<T['_id'], T>;
|
||||
/**
|
||||
* Checks if a document with the given _id exists in the store.
|
||||
*
|
||||
* @param _id - The _id of the document to check.
|
||||
* @returns true if the document exists, false otherwise.
|
||||
*/
|
||||
has(_id: T['_id']): boolean;
|
||||
/**
|
||||
* Retrieves a document by its _id.
|
||||
*
|
||||
* @param _id - The _id of the document to retrieve.
|
||||
* @returns The document if found, or undefined if not found.
|
||||
*/
|
||||
get(_id: T['_id']): T | undefined;
|
||||
/**
|
||||
* Checks if any document in the store satisfies the given predicate.
|
||||
*
|
||||
* @param predicate - A function that takes a document and returns true if it matches the condition.
|
||||
* @returns true if at least one document matches the predicate, false otherwise.
|
||||
*/
|
||||
some(predicate: (record: T) => boolean): boolean;
|
||||
/**
|
||||
* Finds a document that satisfies the given predicate.
|
||||
*
|
||||
* @param predicate - A function that takes a document and returns true if it matches the condition.
|
||||
* @returns The first document that matches the predicate, or undefined if no document matches.
|
||||
*/
|
||||
find<U extends T>(predicate: (record: T) => record is U): U | undefined;
|
||||
/**
|
||||
* Finds a document that satisfies the given predicate.
|
||||
*
|
||||
* @param predicate - A function that takes a document and returns true if it matches the condition.
|
||||
* @returns The first document that matches the predicate, or undefined if no document matches.
|
||||
*/
|
||||
find(predicate: (record: T) => boolean): T | undefined;
|
||||
/**
|
||||
* Finds the first document that satisfies the given predicate, using a comparator to determine the best match.
|
||||
*
|
||||
* Usually the "best" document is the first of a ordered set, but it can be any criteria defined by the comparator.
|
||||
*
|
||||
* @param predicate - A function that takes a document and returns true if it matches the condition.
|
||||
* @param comparator - A function that compares two documents and returns a negative number if the first is better, zero if they are equal, or a positive number if the second is better.
|
||||
* @returns The best matching document according to the predicate and comparator, or undefined if no document matches.
|
||||
*/
|
||||
findFirst<U extends T>(predicate: (record: T) => record is U, comparator: (a: T, b: T) => number): U | undefined;
|
||||
/**
|
||||
* Finds the first document that satisfies the given predicate, using a comparator to determine the best match.
|
||||
*
|
||||
* Usually the "best" document is the first of a ordered set, but it can be any criteria defined by the comparator.
|
||||
*
|
||||
* @param predicate - A function that takes a document and returns true if it matches the condition.
|
||||
* @param comparator - A function that compares two documents and returns a negative number if the first is better, zero if they are equal, or a positive number if the second is better.
|
||||
* @returns The best matching document according to the predicate and comparator, or undefined if no document matches.
|
||||
*/
|
||||
findFirst(predicate: (record: T) => boolean, comparator: (a: T, b: T) => number): T | undefined;
|
||||
/**
|
||||
* Filters documents in the store based on a predicate.
|
||||
*
|
||||
* @param predicate - A function that takes a document and returns true if it matches the condition.
|
||||
* @returns An array of documents that match the predicate.
|
||||
*/
|
||||
filter<U extends T>(predicate: (record: T) => record is U): U[];
|
||||
/**
|
||||
* Filters documents in the store based on a predicate.
|
||||
*
|
||||
* @param predicate - A function that takes a document and returns true if it matches the condition.
|
||||
* @returns An array of documents that match the predicate.
|
||||
*/
|
||||
filter(predicate: (record: T) => boolean): T[];
|
||||
/**
|
||||
* Creates an index of documents by a specified key.
|
||||
*
|
||||
* @param key - The key to index the documents by.
|
||||
* @returns A Map where the keys are the values of the specified key in the documents, and the values are the documents themselves.
|
||||
*/
|
||||
indexBy<TKey extends keyof T>(key: TKey): ReadonlyMap<T[TKey], T>;
|
||||
/**
|
||||
* Replaces all documents in the store with the provided records.
|
||||
*
|
||||
* @param records - An array of documents to replace the current records in the store.
|
||||
*/
|
||||
replaceAll(records: T[]): void;
|
||||
/**
|
||||
* Stores a single document in the store.
|
||||
*
|
||||
* @param doc - The document to store.
|
||||
*/
|
||||
store(doc: T): void;
|
||||
/**
|
||||
* Stores multiple documents in the store.
|
||||
*
|
||||
* @param docs - An iterable of documents to store.
|
||||
*/
|
||||
storeMany(docs: Iterable<T>): void;
|
||||
/**
|
||||
* Deletes a document from the store by its _id.
|
||||
*
|
||||
* @param _id - The _id of the document to delete.
|
||||
*/
|
||||
delete(_id: T['_id']): void;
|
||||
/**
|
||||
* Updates documents in the store that match a predicate.
|
||||
*
|
||||
* @param predicate - A function that takes a document and returns true if it matches the condition.
|
||||
* @param modifier - A function that takes a document and returns the modified document.
|
||||
* @returns void
|
||||
*/
|
||||
update<U extends T>(predicate: (record: T) => record is U, modifier: (record: U) => U): void;
|
||||
/**
|
||||
* Updates documents in the store that match a predicate.
|
||||
*
|
||||
* @param predicate - A function that takes a document and returns true if it matches the condition.
|
||||
* @param modifier - A function that takes a document and returns the modified document.
|
||||
* @returns void
|
||||
*/
|
||||
update(predicate: (record: T) => boolean, modifier: (record: T) => T): void;
|
||||
/**
|
||||
* Asynchronously updates documents in the store that match a predicate.
|
||||
*
|
||||
* @param predicate - A function that takes a document and returns true if it matches the condition.
|
||||
* @param modifier - A function that takes a document and returns a Promise that resolves to the modified document.
|
||||
* @returns void
|
||||
*/
|
||||
updateAsync<U extends T>(predicate: (record: T) => record is U, modifier: (record: U) => Promise<U>): Promise<void>;
|
||||
/**
|
||||
* Asynchronously updates documents in the store that match a predicate.
|
||||
*
|
||||
* @param predicate - A function that takes a document and returns true if it matches the condition.
|
||||
* @param modifier - A function that takes a document and returns a Promise that resolves to the modified document.
|
||||
* @returns void
|
||||
*/
|
||||
updateAsync(predicate: (record: T) => boolean, modifier: (record: T) => Promise<T>): Promise<void>;
|
||||
/**
|
||||
* Removes documents from the store that match a predicate.
|
||||
*
|
||||
* @param predicate - A function that takes a document and returns true if it matches the condition.
|
||||
*/
|
||||
remove(predicate: (record: T) => boolean): void;
|
||||
count(predicate: (record: T) => boolean): number;
|
||||
}
|
||||
|
||||
export interface IDocumentMapStoreHooks<T extends { _id: string }> {
|
||||
/**
|
||||
* Callback invoked when a document is stored, updated or deleted.
|
||||
*
|
||||
* This is useful to recompute Minimongo queries that depend on the changed documents.
|
||||
* @deprecated prefer subscribing to the store
|
||||
*/
|
||||
onInvalidate?: (...docs: T[]) => void;
|
||||
/**
|
||||
* Callback invoked when all documents are replaced in the store.
|
||||
*
|
||||
* This is useful to recompute Minimongo queries that depend on the changed documents.
|
||||
* @deprecated prefer subscribing to the store
|
||||
*/
|
||||
onInvalidateAll?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create a Zustand store that holds a map of documents.
|
||||
*
|
||||
* @param options - Optional callbacks to handle invalidation of documents.
|
||||
* @returns the Zustand store with methods to manage the document map.
|
||||
*/
|
||||
export const createDocumentMapStore = <T extends { _id: string }>({ onInvalidate, onInvalidateAll }: IDocumentMapStoreHooks<T> = {}) =>
|
||||
create<IDocumentMapStore<T>>()((set, get) => ({
|
||||
records: new Map(),
|
||||
has: (id: T['_id']) => get().records.has(id),
|
||||
get: (id: T['_id']) => get().records.get(id),
|
||||
some: (predicate: (record: T) => boolean) => {
|
||||
for (const record of get().records.values()) {
|
||||
if (predicate(record)) return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
find: (predicate: (record: T) => boolean) => {
|
||||
for (const record of get().records.values()) {
|
||||
if (predicate(record)) return record;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
findFirst: (predicate: (record: T) => boolean, comparator: (a: T, b: T) => number) => {
|
||||
let best: T | undefined;
|
||||
for (const record of get().records.values()) {
|
||||
if (!predicate(record)) continue;
|
||||
if (best === undefined || comparator(record, best) < 0) {
|
||||
best = record;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
},
|
||||
filter: (predicate: (record: T) => boolean) => {
|
||||
const results: T[] = [];
|
||||
for (const record of get().records.values()) {
|
||||
if (predicate(record)) {
|
||||
results.push(record);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
},
|
||||
indexBy: <TKey extends keyof T>(key: TKey) => {
|
||||
const index = new Map<T[TKey], T>();
|
||||
|
||||
for (const record of get().records.values()) {
|
||||
const keyValue = record[key];
|
||||
index.set(keyValue, record);
|
||||
}
|
||||
|
||||
return index;
|
||||
},
|
||||
replaceAll: (records: T[]) => {
|
||||
set({ records: new Map(records.map((record) => [record._id, record])) });
|
||||
onInvalidateAll?.();
|
||||
},
|
||||
store: (doc) => {
|
||||
set((state) => ({ records: new Map(state.records).set(doc._id, doc) }));
|
||||
onInvalidate?.(doc);
|
||||
},
|
||||
storeMany: (docs) => {
|
||||
set((state) => {
|
||||
const records = new Map(state.records);
|
||||
|
||||
for (const doc of docs) {
|
||||
records.set(doc._id, doc);
|
||||
}
|
||||
|
||||
return { records };
|
||||
});
|
||||
onInvalidate?.(...docs);
|
||||
},
|
||||
delete: (_id) => {
|
||||
const affected: T[] = [];
|
||||
set((state) => {
|
||||
const records = new Map(state.records);
|
||||
if (onInvalidate) affected.push(state.records.get(_id)!);
|
||||
records.delete(_id);
|
||||
return { records };
|
||||
});
|
||||
onInvalidate?.(...affected);
|
||||
},
|
||||
update: (predicate: (record: T) => boolean, modifier: (record: T) => T) => {
|
||||
const affected: T[] = [];
|
||||
|
||||
set((state) => {
|
||||
const records = new Map<T['_id'], T>();
|
||||
for (const record of state.records.values()) {
|
||||
if (predicate(record)) {
|
||||
const newRecord = modifier(record);
|
||||
records.set(record._id, newRecord);
|
||||
if (onInvalidate) affected.push(newRecord);
|
||||
} else {
|
||||
records.set(record._id, record);
|
||||
}
|
||||
}
|
||||
|
||||
return { records };
|
||||
});
|
||||
onInvalidate?.(...affected);
|
||||
},
|
||||
updateAsync: async (predicate: (record: T) => boolean, modifier: (record: T) => Promise<T>) => {
|
||||
const affected: T[] = [];
|
||||
|
||||
const records = new Map<T['_id'], T>();
|
||||
|
||||
for await (const record of get().records.values()) {
|
||||
if (predicate(record)) {
|
||||
const newRecord = await modifier(record);
|
||||
records.set(record._id, newRecord);
|
||||
if (onInvalidate) affected.push(newRecord);
|
||||
} else {
|
||||
records.set(record._id, record);
|
||||
}
|
||||
}
|
||||
|
||||
set({ records });
|
||||
onInvalidate?.(...affected);
|
||||
},
|
||||
remove: (predicate: (record: T) => boolean) => {
|
||||
const affected: T[] = [];
|
||||
|
||||
set((state) => {
|
||||
const records = new Map<T['_id'], T>();
|
||||
for (const record of state.records.values()) {
|
||||
if (predicate(record)) {
|
||||
if (onInvalidate) affected.push(record);
|
||||
continue;
|
||||
}
|
||||
records.set(record._id, record);
|
||||
}
|
||||
|
||||
return { records };
|
||||
});
|
||||
onInvalidate?.(...affected);
|
||||
},
|
||||
count: (predicate: (record: T) => boolean) => {
|
||||
let results = 0;
|
||||
for (const record of get().records.values()) {
|
||||
if (predicate(record)) {
|
||||
results += 1;
|
||||
}
|
||||
}
|
||||
return results;
|
||||
},
|
||||
}));
|
||||
55
apps/meteor/client/lib/cachedStores/applyQueryOptions.ts
Normal file
55
apps/meteor/client/lib/cachedStores/applyQueryOptions.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { FindOptions } from '@rocket.chat/ui-contexts';
|
||||
|
||||
import { pipe } from './pipe';
|
||||
|
||||
type OriginalStructure = FindOptions['sort'];
|
||||
|
||||
type SortField = 'lm' | 'lowerCaseFName' | 'lowerCaseName';
|
||||
type SortDirection = -1 | 1;
|
||||
|
||||
type SortObject = {
|
||||
field: SortField;
|
||||
direction: SortDirection;
|
||||
}[];
|
||||
|
||||
/**
|
||||
* Converts a MongoDB-style sort structure to a sort object.
|
||||
*/
|
||||
const convertSort = (original: OriginalStructure): SortObject => {
|
||||
const convertedSort: SortObject = [];
|
||||
|
||||
if (!original) {
|
||||
return convertedSort;
|
||||
}
|
||||
Object.keys(original as Record<string, any>).forEach((key) => {
|
||||
const direction = original[key as keyof OriginalStructure];
|
||||
|
||||
if (direction === -1 || direction === 1) {
|
||||
convertedSort.push({
|
||||
field: key as SortField,
|
||||
direction,
|
||||
});
|
||||
}
|
||||
});
|
||||
return convertedSort;
|
||||
};
|
||||
|
||||
export const applyQueryOptions = <T extends Record<string, any>>(records: T[], options: FindOptions): T[] => {
|
||||
let currentPipeline = pipe(records);
|
||||
if (options.sort) {
|
||||
const sortObj = convertSort(options.sort);
|
||||
for (let i = sortObj.length - 1; i >= 0; i--) {
|
||||
const { field, direction } = sortObj[i];
|
||||
currentPipeline = currentPipeline.sortByField(field, direction);
|
||||
}
|
||||
}
|
||||
if (options.skip) {
|
||||
currentPipeline = currentPipeline.slice(options.skip, records.length);
|
||||
}
|
||||
if (options.limit !== undefined) {
|
||||
// If skip was applied, limit will be applied on the already skipped array
|
||||
// If no skip, it will be applied from the beginning.
|
||||
currentPipeline = currentPipeline.slice(0, options.limit);
|
||||
}
|
||||
return currentPipeline.apply();
|
||||
};
|
||||
14
apps/meteor/client/lib/cachedStores/createGlobalStore.ts
Normal file
14
apps/meteor/client/lib/cachedStores/createGlobalStore.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { StoreApi, UseBoundStore } from 'zustand';
|
||||
|
||||
import type { IDocumentMapStore } from './DocumentMapStore';
|
||||
|
||||
export const createGlobalStore = <T extends { _id: string }, U>(store: UseBoundStore<StoreApi<IDocumentMapStore<T>>>, extension?: U) =>
|
||||
Object.assign(
|
||||
{
|
||||
use: store,
|
||||
get state(): IDocumentMapStore<T> {
|
||||
return this.use.getState();
|
||||
},
|
||||
} as const,
|
||||
extension,
|
||||
);
|
||||
213
apps/meteor/client/lib/cachedStores/pipe.spec.ts
Normal file
213
apps/meteor/client/lib/cachedStores/pipe.spec.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import { pipe } from './pipe';
|
||||
|
||||
describe('pipe', () => {
|
||||
interface ITestData {
|
||||
_id: number;
|
||||
name: string;
|
||||
ts: number;
|
||||
date: Date;
|
||||
}
|
||||
|
||||
const sampleData: ITestData[] = [
|
||||
{ _id: 1, name: 'Alice', ts: 30, date: new Date('2025-07-16T01:00:00.000Z') },
|
||||
{ _id: 2, name: 'Charlie', ts: 25, date: new Date('2025-07-16T02:00:00.000Z') },
|
||||
{ _id: 3, name: 'Bob', ts: 35, date: new Date('2025-07-16T03:00:00.000Z') },
|
||||
{ _id: 4, name: 'David', ts: 20, date: new Date('2025-07-16T04:00:00.000Z') },
|
||||
{ _id: 5, name: 'Eve', ts: 40, date: new Date('2025-07-16T05:00:00.000Z') },
|
||||
];
|
||||
|
||||
describe('missing fields', () => {
|
||||
it('should let the rows with missing fields at the end', () => {
|
||||
const arr = [...sampleData];
|
||||
arr.splice(Math.floor(Math.random() * arr.length), 0, { _id: 6, name: 'A', ts: 50 } as ITestData);
|
||||
|
||||
const result = pipe<ITestData>().sortByField('date', 1).apply(arr);
|
||||
expect(result).toEqual([...sampleData, { _id: 6, name: 'A', ts: 50 }]);
|
||||
});
|
||||
it('should sort by fallback fields if a field is missing', () => {
|
||||
// adds randomly an row if missing date
|
||||
const arr = [...sampleData];
|
||||
arr.splice(Math.floor(Math.random() * arr.length), 0, { _id: 6, name: 'A', ts: 50 } as ITestData);
|
||||
arr.splice(Math.floor(Math.random() * arr.length), 0, { _id: 7, name: 'B', ts: 50 } as ITestData);
|
||||
arr.splice(Math.floor(Math.random() * arr.length), 0, { _id: 8, name: 'C', ts: 50 } as ITestData);
|
||||
|
||||
const result = pipe<ITestData>().sortByField('date', 1, ['name']).apply(arr);
|
||||
expect(result).toEqual([...sampleData, { _id: 6, name: 'A', ts: 50 }, { _id: 7, name: 'B', ts: 50 }, { _id: 8, name: 'C', ts: 50 }]);
|
||||
});
|
||||
it('should sort by fallback fields if a field is missing with second signature', () => {
|
||||
// adds randomly an row if missing date
|
||||
const arr = [...sampleData];
|
||||
arr.splice(Math.floor(Math.random() * arr.length), 0, { _id: 6, name: 'A', ts: 50 } as ITestData);
|
||||
arr.splice(Math.floor(Math.random() * arr.length), 0, { _id: 7, name: 'B', ts: 50 } as ITestData);
|
||||
arr.splice(Math.floor(Math.random() * arr.length), 0, { _id: 8, name: 'C', ts: 50 } as ITestData);
|
||||
|
||||
const result = pipe<ITestData>().sortByField(['date', 'name'], 1).apply(arr);
|
||||
expect(result).toEqual([...sampleData, { _id: 6, name: 'A', ts: 50 }, { _id: 7, name: 'B', ts: 50 }, { _id: 8, name: 'C', ts: 50 }]);
|
||||
});
|
||||
});
|
||||
|
||||
it('should correctly slice the array with skip and limit', () => {
|
||||
const result = pipe<ITestData>().slice(1, 2).apply(sampleData);
|
||||
expect(result).toEqual([
|
||||
{ _id: 2, name: 'Charlie', ts: 25, date: new Date('2025-07-16T02:00:00.000Z') },
|
||||
{ _id: 3, name: 'Bob', ts: 35, date: new Date('2025-07-16T03:00:00.000Z') },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should correctly slice the array with only limit', () => {
|
||||
const result = pipe<ITestData>().slice(0, 3).apply(sampleData);
|
||||
expect(result).toEqual([
|
||||
{ _id: 1, name: 'Alice', ts: 30, date: new Date('2025-07-16T01:00:00.000Z') },
|
||||
{ _id: 2, name: 'Charlie', ts: 25, date: new Date('2025-07-16T02:00:00.000Z') },
|
||||
{ _id: 3, name: 'Bob', ts: 35, date: new Date('2025-07-16T03:00:00.000Z') },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return an empty array if skip is greater than array length', () => {
|
||||
const result = pipe<ITestData>().slice(10, 2).apply(sampleData);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return remaining elements if limit is greater than available', () => {
|
||||
const result = pipe<ITestData>().slice(3, 10).apply(sampleData);
|
||||
expect(result).toEqual([
|
||||
{ _id: 4, name: 'David', ts: 20, date: new Date('2025-07-16T04:00:00.000Z') },
|
||||
{ _id: 5, name: 'Eve', ts: 40, date: new Date('2025-07-16T05:00:00.000Z') },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should sort the array by a string field in ascending order', () => {
|
||||
const result = pipe<ITestData>().sortByField('name', 1).apply(sampleData);
|
||||
expect(result).toEqual([
|
||||
{ _id: 1, name: 'Alice', ts: 30, date: new Date('2025-07-16T01:00:00.000Z') },
|
||||
{ _id: 3, name: 'Bob', ts: 35, date: new Date('2025-07-16T03:00:00.000Z') },
|
||||
{ _id: 2, name: 'Charlie', ts: 25, date: new Date('2025-07-16T02:00:00.000Z') },
|
||||
{ _id: 4, name: 'David', ts: 20, date: new Date('2025-07-16T04:00:00.000Z') },
|
||||
{ _id: 5, name: 'Eve', ts: 40, date: new Date('2025-07-16T05:00:00.000Z') },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should sort the array by a date field in ascending order', () => {
|
||||
const result = pipe<ITestData>()
|
||||
.sortByField('date', 1)
|
||||
.apply([...sampleData].sort(() => Math.random() - 0.5));
|
||||
expect(result).toEqual(sampleData);
|
||||
});
|
||||
|
||||
it('should sort the array by a string field in descending order', () => {
|
||||
const result = pipe<ITestData>().sortByField('name', -1).apply(sampleData);
|
||||
expect(result).toEqual([
|
||||
{ _id: 5, name: 'Eve', ts: 40, date: new Date('2025-07-16T05:00:00.000Z') },
|
||||
{ _id: 4, name: 'David', ts: 20, date: new Date('2025-07-16T04:00:00.000Z') },
|
||||
{ _id: 2, name: 'Charlie', ts: 25, date: new Date('2025-07-16T02:00:00.000Z') },
|
||||
{ _id: 3, name: 'Bob', ts: 35, date: new Date('2025-07-16T03:00:00.000Z') },
|
||||
{ _id: 1, name: 'Alice', ts: 30, date: new Date('2025-07-16T01:00:00.000Z') },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should sort the array by a number field in ascending order', () => {
|
||||
const result = pipe<ITestData>().sortByField('ts', 1).apply(sampleData);
|
||||
expect(result).toEqual([
|
||||
{ _id: 4, name: 'David', ts: 20, date: new Date('2025-07-16T04:00:00.000Z') },
|
||||
{ _id: 2, name: 'Charlie', ts: 25, date: new Date('2025-07-16T02:00:00.000Z') },
|
||||
{ _id: 1, name: 'Alice', ts: 30, date: new Date('2025-07-16T01:00:00.000Z') },
|
||||
{ _id: 3, name: 'Bob', ts: 35, date: new Date('2025-07-16T03:00:00.000Z') },
|
||||
{ _id: 5, name: 'Eve', ts: 40, date: new Date('2025-07-16T05:00:00.000Z') },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should sort the array by a number field in descending order', () => {
|
||||
const result = pipe<ITestData>().sortByField('ts', -1).apply(sampleData);
|
||||
expect(result).toEqual([
|
||||
{ _id: 5, name: 'Eve', ts: 40, date: new Date('2025-07-16T05:00:00.000Z') },
|
||||
{ _id: 3, name: 'Bob', ts: 35, date: new Date('2025-07-16T03:00:00.000Z') },
|
||||
{ _id: 1, name: 'Alice', ts: 30, date: new Date('2025-07-16T01:00:00.000Z') },
|
||||
{ _id: 2, name: 'Charlie', ts: 25, date: new Date('2025-07-16T02:00:00.000Z') },
|
||||
{ _id: 4, name: 'David', ts: 20, date: new Date('2025-07-16T04:00:00.000Z') },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should apply chained operations correctly (sort then slice)', () => {
|
||||
const result = pipe<ITestData>().sortByField('ts').slice(1, 3).apply(sampleData);
|
||||
expect(result).toEqual([
|
||||
{ _id: 2, name: 'Charlie', ts: 25, date: new Date('2025-07-16T02:00:00.000Z') },
|
||||
{ _id: 1, name: 'Alice', ts: 30, date: new Date('2025-07-16T01:00:00.000Z') },
|
||||
{ _id: 3, name: 'Bob', ts: 35, date: new Date('2025-07-16T03:00:00.000Z') },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should apply chained operations correctly (slice then sort)', () => {
|
||||
const result = pipe<ITestData>().slice(0, 4).sortByField('name', -1).apply(sampleData);
|
||||
expect(result).toEqual([
|
||||
{ _id: 4, name: 'David', ts: 20, date: new Date('2025-07-16T04:00:00.000Z') },
|
||||
{ _id: 2, name: 'Charlie', ts: 25, date: new Date('2025-07-16T02:00:00.000Z') },
|
||||
{ _id: 3, name: 'Bob', ts: 35, date: new Date('2025-07-16T03:00:00.000Z') },
|
||||
{ _id: 1, name: 'Alice', ts: 30, date: new Date('2025-07-16T01:00:00.000Z') },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return an empty array when applying to an empty array', () => {
|
||||
const result = pipe<ITestData>().slice(0, 1).apply([]);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should combine two pipelines using the pipe method', () => {
|
||||
const pipeline1 = pipe<ITestData>().sortByField('name');
|
||||
const pipeline2 = pipe<ITestData>().slice(1, 2);
|
||||
const combinedPipeline = pipe<ITestData>().pipe(pipeline1).pipe(pipeline2);
|
||||
|
||||
const result = combinedPipeline.apply(sampleData);
|
||||
expect(result).toEqual([
|
||||
{ _id: 3, name: 'Bob', ts: 35, date: new Date('2025-07-16T03:00:00.000Z') },
|
||||
{ _id: 2, name: 'Charlie', ts: 25, date: new Date('2025-07-16T02:00:00.000Z') },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should combine pipelines and maintain initial operations', () => {
|
||||
const pipeline1 = pipe<ITestData>().sortByField('ts');
|
||||
const pipeline2 = pipe<ITestData>().slice(2, 2);
|
||||
const combinedPipeline = pipeline1.pipe(pipeline2);
|
||||
|
||||
const result = combinedPipeline.apply(sampleData);
|
||||
// Expected order after ts sort: David, Charlie, Alice, Bob, Eve
|
||||
// Slice(2,2) should take: Alice, Bob
|
||||
expect(result).toEqual([
|
||||
{ _id: 1, name: 'Alice', ts: 30, date: new Date('2025-07-16T01:00:00.000Z') },
|
||||
{ _id: 3, name: 'Bob', ts: 35, date: new Date('2025-07-16T03:00:00.000Z') },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle non-existent fields in sortByField without throwing and without changing the order', () => {
|
||||
const result = pipe<ITestData>()
|
||||
.sortByField('nonExistentField' as keyof ITestData)
|
||||
.apply(sampleData);
|
||||
expect(result).toEqual(sampleData);
|
||||
});
|
||||
|
||||
it('should return the original array if no operations are applied', () => {
|
||||
const result = pipe<ITestData>().apply(sampleData);
|
||||
expect(result).toEqual(sampleData);
|
||||
});
|
||||
|
||||
it('should not modify the original array', () => {
|
||||
const originalData = [...sampleData];
|
||||
const result = pipe<ITestData>().sortByField('ts').apply(sampleData);
|
||||
expect(sampleData).toEqual(originalData);
|
||||
expect(result).not.toEqual(originalData);
|
||||
});
|
||||
|
||||
describe('inferred type by initial data', () => {
|
||||
it('should return the original array if no operations are applied', () => {
|
||||
const result = pipe(sampleData).apply();
|
||||
expect(result).toEqual(sampleData);
|
||||
});
|
||||
|
||||
it('should slice the array with skip and limit', () => {
|
||||
const result = pipe(sampleData).slice(1, 2).apply();
|
||||
expect(result).toEqual([
|
||||
{ _id: 2, name: 'Charlie', ts: 25, date: new Date('2025-07-16T02:00:00.000Z') },
|
||||
{ _id: 3, name: 'Bob', ts: 35, date: new Date('2025-07-16T03:00:00.000Z') },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
90
apps/meteor/client/lib/cachedStores/pipe.ts
Normal file
90
apps/meteor/client/lib/cachedStores/pipe.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
interface IPipeReturn<D> {
|
||||
slice(skip: number, limit: number): IPipeReturn<D>;
|
||||
sortByField(fieldName: keyof D, direction?: 1 | -1, fallback?: Array<keyof D>): IPipeReturn<D>;
|
||||
sortByField(fieldName: Array<keyof D>, direction?: 1 | -1, fallback?: Array<keyof D>): IPipeReturn<D>;
|
||||
apply(): D[];
|
||||
pipe(p: IPipeReturn<D>): IPipeReturn<D>;
|
||||
}
|
||||
|
||||
interface IPipeReturnNoInitialData<D> {
|
||||
slice(skip: number, limit: number): IPipeReturnNoInitialData<D>;
|
||||
sortByField(fieldName: keyof D, direction?: 1 | -1, fallback?: Array<keyof D>): IPipeReturnNoInitialData<D>;
|
||||
sortByField(fieldName: Array<keyof D>, direction?: 1 | -1): IPipeReturnNoInitialData<D>;
|
||||
apply(arg: D[]): D[];
|
||||
pipe(p: IPipeReturnNoInitialData<D>): IPipeReturnNoInitialData<D>;
|
||||
}
|
||||
|
||||
type PipeFunction<D> = (arg: D[]) => D[];
|
||||
|
||||
const merge =
|
||||
<D>(fn: PipeFunction<D>, inner: PipeFunction<D>): PipeFunction<D> =>
|
||||
(args) =>
|
||||
fn(inner(args));
|
||||
|
||||
export function pipe<D>(): IPipeReturnNoInitialData<D>;
|
||||
export function pipe<D>(initialData: D[]): IPipeReturn<D>;
|
||||
export function pipe<D>(initialData: D[], acc: PipeFunction<D>): IPipeReturn<D>;
|
||||
|
||||
export function pipe<D>(
|
||||
initialData?: any,
|
||||
acc: PipeFunction<D> = (arg) => [...arg],
|
||||
): typeof initialData extends undefined ? IPipeReturnNoInitialData<D> : IPipeReturn<D> {
|
||||
return {
|
||||
slice(skip = 0, limit: number) {
|
||||
return pipe<D>(
|
||||
initialData,
|
||||
merge<D>((arr: D[]) => arr.slice(skip, skip + limit), acc),
|
||||
);
|
||||
},
|
||||
|
||||
sortByField(fieldName: keyof D | Array<keyof D>, direction: 1 | -1 = 1, fallback: Array<keyof D> = []) {
|
||||
if (Array.isArray(fieldName)) {
|
||||
return this.sortByField(fieldName[0], direction, fieldName.slice(1));
|
||||
}
|
||||
|
||||
const sort = (fieldName: keyof D, direction: 1 | -1, fallback: Array<keyof D>) => {
|
||||
return (a: D, b: D): number => {
|
||||
const aValue = a[fieldName];
|
||||
const bValue = b[fieldName];
|
||||
|
||||
if (aValue === undefined && bValue === undefined && fallback.length > 0) {
|
||||
const [field, ...rest] = fallback;
|
||||
return sort(field, direction, rest)(a, b);
|
||||
}
|
||||
|
||||
if (aValue === undefined) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (bValue === undefined) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (aValue instanceof Date && bValue instanceof Date) {
|
||||
return direction * (aValue.getTime() - bValue.getTime());
|
||||
}
|
||||
|
||||
if (typeof aValue === 'string' && typeof bValue === 'string') {
|
||||
return direction * aValue.localeCompare(bValue);
|
||||
}
|
||||
if (typeof aValue === 'number' && typeof bValue === 'number') {
|
||||
return direction * (aValue - bValue);
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
};
|
||||
|
||||
return pipe<D>(
|
||||
initialData,
|
||||
merge((arr: D[]) => [...arr].sort(sort(fieldName, direction, fallback)), acc),
|
||||
);
|
||||
},
|
||||
|
||||
apply: (arg: D[]) => {
|
||||
return acc(initialData ?? arg);
|
||||
},
|
||||
pipe(p: IPipeReturn<D>) {
|
||||
return pipe<D>(initialData, merge(p.apply, acc));
|
||||
},
|
||||
};
|
||||
}
|
||||
186
apps/meteor/client/lib/chats/ChatAPI.ts
Normal file
186
apps/meteor/client/lib/chats/ChatAPI.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import type { IMessage, IRoom, ISubscription, IE2EEMessage, IUpload } from '@rocket.chat/core-typings';
|
||||
import type { IActionManager } from '@rocket.chat/ui-contexts';
|
||||
import type { RefObject } from 'react';
|
||||
|
||||
import type { Upload, EncryptedFile } from './Upload';
|
||||
import type { ReadStateManager } from './readStateManager';
|
||||
import type { FormattingButton } from '../messageBoxFormatting';
|
||||
|
||||
type Subscribable<T> = {
|
||||
get(): T;
|
||||
subscribe(callback: () => void): () => void;
|
||||
};
|
||||
|
||||
export type ComposerAPI = {
|
||||
release(): void;
|
||||
readonly text: string;
|
||||
readonly selection: { readonly start: number; readonly end: number };
|
||||
setText(
|
||||
text: string,
|
||||
options?: {
|
||||
selection?:
|
||||
| { readonly start?: number; readonly end?: number }
|
||||
| ((previous: { readonly start: number; readonly end: number }) => { readonly start?: number; readonly end?: number });
|
||||
},
|
||||
): void;
|
||||
wrapSelection(pattern: string): {
|
||||
selectionStart: number;
|
||||
selectionEnd: number;
|
||||
value: string;
|
||||
};
|
||||
insertText(text: string): void;
|
||||
insertNewLine(): void;
|
||||
clear(): void;
|
||||
focus(): void;
|
||||
blur(): void;
|
||||
|
||||
getCursorPosition(): number | undefined;
|
||||
|
||||
substring(start: number, end?: number): string;
|
||||
|
||||
replaceText(
|
||||
text: string,
|
||||
selection: {
|
||||
start: number;
|
||||
end: number;
|
||||
},
|
||||
): void;
|
||||
|
||||
setCursorToEnd(): void;
|
||||
setCursorToStart(): void;
|
||||
replyWith(text: string): Promise<void>;
|
||||
quoteMessage(message: IMessage): Promise<void>;
|
||||
dismissQuotedMessage(mid: IMessage['_id']): Promise<void>;
|
||||
dismissAllQuotedMessages(): Promise<void>;
|
||||
readonly quotedMessages: Subscribable<IMessage[]>;
|
||||
|
||||
setEditingMode(editing: boolean): void;
|
||||
readonly editing: Subscribable<boolean>;
|
||||
|
||||
setRecordingMode(recording: boolean): void;
|
||||
readonly recording: Subscribable<boolean>;
|
||||
|
||||
setRecordingVideo(recording: boolean): void;
|
||||
readonly recordingVideo: Subscribable<boolean>;
|
||||
|
||||
setIsMicrophoneDenied(isMicrophoneDenied: boolean): void;
|
||||
readonly isMicrophoneDenied: Subscribable<boolean>;
|
||||
|
||||
readonly formatters: Subscribable<FormattingButton[]>;
|
||||
|
||||
readonly composerRef: RefObject<HTMLElement | null>;
|
||||
|
||||
readonly uploads: UploadsAPI;
|
||||
};
|
||||
|
||||
export type DataAPI = {
|
||||
composeMessage(
|
||||
text: string,
|
||||
options: { sendToChannel?: boolean; quotedMessages: IMessage[]; originalMessage?: IMessage | null },
|
||||
): Promise<IMessage>;
|
||||
findMessageByID(mid: IMessage['_id']): Promise<IMessage | null>;
|
||||
getMessageByID(mid: IMessage['_id']): Promise<IMessage>;
|
||||
findLastMessage(): Promise<IMessage | undefined>;
|
||||
getLastMessage(): Promise<IMessage>;
|
||||
findPreviousOwnMessage(message?: IMessage): Promise<IMessage | undefined>;
|
||||
getPreviousOwnMessage(message: IMessage): Promise<IMessage>;
|
||||
findNextOwnMessage(message: IMessage): Promise<IMessage | undefined>;
|
||||
getNextOwnMessage(message: IMessage): Promise<IMessage>;
|
||||
pushEphemeralMessage(message: Omit<IMessage, 'rid' | 'tmid'>): Promise<void>;
|
||||
canUpdateMessage(message: IMessage): Promise<boolean>;
|
||||
updateMessage(message: Pick<IMessage, '_id' | 't'> & Partial<Omit<IMessage, '_id' | 't'>>, previewUrls?: string[]): Promise<void>;
|
||||
canDeleteMessage(message: IMessage): Promise<boolean>;
|
||||
deleteMessage(msgIdOrMsg: IMessage | IMessage['_id']): Promise<void>;
|
||||
getDraft(mid: IMessage['_id'] | undefined): Promise<string | undefined>;
|
||||
discardDraft(mid: IMessage['_id'] | undefined): Promise<void>;
|
||||
saveDraft(mid: IMessage['_id'] | undefined, text: string): Promise<void>;
|
||||
findRoom(): Promise<IRoom | undefined>;
|
||||
getRoom(): Promise<IRoom>;
|
||||
isSubscribedToRoom(): Promise<boolean>;
|
||||
joinRoom(): Promise<void>;
|
||||
findDiscussionByID(drid: IRoom['_id']): Promise<IRoom | undefined>;
|
||||
getDiscussionByID(drid: IRoom['_id']): Promise<IRoom>;
|
||||
findSubscription(): Promise<ISubscription | undefined>;
|
||||
getSubscription(): Promise<ISubscription>;
|
||||
findSubscriptionFromMessage(message: IMessage): Promise<ISubscription | undefined>;
|
||||
getSubscriptionFromMessage(message: IMessage): Promise<ISubscription>;
|
||||
};
|
||||
|
||||
export type EncryptedFileUploadContent = {
|
||||
rawFile: File;
|
||||
fileContent: { raw: Partial<IUpload>; encrypted?: IE2EEMessage['content'] };
|
||||
encryptedFile: EncryptedFile;
|
||||
};
|
||||
|
||||
export type UploadsAPI = {
|
||||
get(): readonly Upload[];
|
||||
subscribe(callback: () => void): () => void;
|
||||
wipeFailedOnes(): void;
|
||||
clear(): void;
|
||||
getProcessingUploads(): boolean;
|
||||
setProcessingUploads(processing: boolean): void;
|
||||
cancel(id: Upload['id']): void;
|
||||
removeUpload(id: Upload['id']): void;
|
||||
editUploadFileName: (id: Upload['id'], fileName: string) => void;
|
||||
editUploadAltText: (id: Upload['id'], altText: string) => void;
|
||||
send(file: File, encrypted?: never): Promise<void>;
|
||||
send(file: File, encrypted: EncryptedFileUploadContent): Promise<void>;
|
||||
};
|
||||
|
||||
export type ChatAPI = {
|
||||
readonly uid: string | undefined;
|
||||
readonly composer?: ComposerAPI;
|
||||
readonly setComposerAPI: (composer?: ComposerAPI) => void;
|
||||
readonly data: DataAPI;
|
||||
readonly readStateManager: ReadStateManager;
|
||||
readonly messageEditing: {
|
||||
toPreviousMessage(): Promise<void>;
|
||||
toNextMessage(): Promise<void>;
|
||||
editMessage(message: IMessage, options?: { cursorAtStart?: boolean }): Promise<void>;
|
||||
};
|
||||
|
||||
readonly currentEditingMessage: {
|
||||
setMID(mid: IMessage['_id']): void;
|
||||
getMID(): string | undefined;
|
||||
reset(): Promise<boolean>;
|
||||
stop(): Promise<void>;
|
||||
cancel(): Promise<void>;
|
||||
};
|
||||
|
||||
readonly emojiPicker: {
|
||||
open(el: Element, cb: (emoji: string) => void): void;
|
||||
close(): void;
|
||||
};
|
||||
|
||||
readonly action: {
|
||||
start(action: 'typing'): void;
|
||||
stop(action: 'typing' | 'recording' | 'uploading' | 'playing'): void;
|
||||
performContinuously(action: 'recording' | 'uploading' | 'playing'): void;
|
||||
};
|
||||
|
||||
ActionManager: IActionManager;
|
||||
|
||||
readonly flows: {
|
||||
readonly uploadFiles: ({ files, resetFileInput }: { files: readonly File[]; resetFileInput?: () => void }) => Promise<void>;
|
||||
readonly sendMessage: ({
|
||||
text,
|
||||
tshow,
|
||||
}: {
|
||||
text: string;
|
||||
tshow?: boolean;
|
||||
previewUrls?: string[];
|
||||
isSlashCommandAllowed?: boolean;
|
||||
tmid?: IMessage['tmid'];
|
||||
}) => Promise<boolean>;
|
||||
readonly processSlashCommand: (message: IMessage, userId: string | null) => Promise<boolean>;
|
||||
readonly processTooLongMessage: (message: IMessage) => Promise<boolean>;
|
||||
readonly processMessageEditing: (
|
||||
message: Pick<IMessage, '_id' | 't'> & Partial<Omit<IMessage, '_id' | 't'>>,
|
||||
previewUrls?: string[],
|
||||
) => Promise<boolean>;
|
||||
readonly processMessageUploads: (message: IMessage) => Promise<boolean>;
|
||||
readonly processSetReaction: (message: Pick<IMessage, 'msg'>) => Promise<boolean>;
|
||||
readonly requestMessageDeletion: (message: IMessage) => Promise<void>;
|
||||
readonly replyBroadcast: (message: IMessage) => Promise<void>;
|
||||
};
|
||||
};
|
||||
28
apps/meteor/client/lib/chats/Upload.ts
Normal file
28
apps/meteor/client/lib/chats/Upload.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { IUpload } from '@rocket.chat/core-typings';
|
||||
|
||||
export type NonEncryptedUpload = {
|
||||
readonly id: string;
|
||||
readonly file: File;
|
||||
readonly url?: string;
|
||||
readonly percentage: number;
|
||||
readonly error?: Error;
|
||||
readonly altText?: string;
|
||||
};
|
||||
|
||||
export type EncryptedUpload = NonEncryptedUpload & {
|
||||
readonly encryptedFile: EncryptedFile;
|
||||
readonly metadataForEncryption: Partial<IUpload>;
|
||||
};
|
||||
|
||||
export type Upload = EncryptedUpload | NonEncryptedUpload;
|
||||
|
||||
export type EncryptedFile = {
|
||||
readonly file: File;
|
||||
readonly key: JsonWebKey;
|
||||
readonly iv: string;
|
||||
readonly type: File['type'];
|
||||
readonly hash: string;
|
||||
};
|
||||
|
||||
export const isEncryptedUpload = (upload: Upload): upload is EncryptedUpload =>
|
||||
'encryptedFile' in upload && upload.encryptedFile !== undefined;
|
||||
349
apps/meteor/client/lib/chats/data.ts
Normal file
349
apps/meteor/client/lib/chats/data.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
import { isEncryptedMessageContent, type IEditedMessage, type IMessage, type IRoom, type ISubscription } from '@rocket.chat/core-typings';
|
||||
import { MessageTypes } from '@rocket.chat/message-types';
|
||||
import { Random } from '@rocket.chat/random';
|
||||
import { differenceInMinutes } from 'date-fns';
|
||||
|
||||
import type { DataAPI } from './ChatAPI';
|
||||
import { Messages, Rooms, Subscriptions } from '../../stores';
|
||||
import { sdk } from '../SDKClient';
|
||||
import { hasAtLeastOnePermission, hasPermission } from '../authorization';
|
||||
import { settings } from '../settings';
|
||||
import { getUserId } from '../user';
|
||||
import { mapMessageFromApi } from '../utils/mapMessageFromApi';
|
||||
import { prependReplies } from '../utils/prependReplies';
|
||||
import { upsertThreadMessageInCache } from '../utils/threadMessageUtils';
|
||||
|
||||
export const createDataAPI = ({ rid, tmid }: { rid: IRoom['_id']; tmid: IMessage['_id'] | undefined }): DataAPI => {
|
||||
const composeMessage = async (
|
||||
text: string,
|
||||
{ sendToChannel, quotedMessages, originalMessage }: { sendToChannel?: boolean; quotedMessages: IMessage[]; originalMessage?: IMessage },
|
||||
): Promise<IMessage> => {
|
||||
const msg = await prependReplies(text, quotedMessages);
|
||||
|
||||
const effectiveRID = originalMessage?.rid ?? rid;
|
||||
const effectiveTMID = originalMessage ? originalMessage.tmid : tmid;
|
||||
|
||||
return {
|
||||
_id: originalMessage?._id ?? Random.id(),
|
||||
rid: effectiveRID,
|
||||
...(effectiveTMID && {
|
||||
tmid: effectiveTMID,
|
||||
...(sendToChannel && { tshow: sendToChannel }),
|
||||
}),
|
||||
msg,
|
||||
} as IMessage;
|
||||
};
|
||||
|
||||
const findMessageByID = async (mid: IMessage['_id']): Promise<IMessage | null> =>
|
||||
Messages.state.find((record) => record._id === mid && record._hidden !== true) ??
|
||||
sdk.rest.get('/v1/chat.getMessage', { msgId: mid }).then((response) => mapMessageFromApi(response.message));
|
||||
|
||||
const getMessageByID = async (mid: IMessage['_id']): Promise<IMessage> => {
|
||||
const message = await findMessageByID(mid);
|
||||
|
||||
if (!message) {
|
||||
throw new Error('Message not found');
|
||||
}
|
||||
|
||||
return message;
|
||||
};
|
||||
|
||||
const findLastMessage = async (): Promise<IMessage | undefined> =>
|
||||
Messages.state.findFirst(
|
||||
(record) => record.rid === rid && (tmid ? record.tmid === tmid : !record.tmid) && record._hidden !== true,
|
||||
(a, b) => b.ts.getTime() - a.ts.getTime(),
|
||||
);
|
||||
|
||||
const getLastMessage = async (): Promise<IMessage> => {
|
||||
const message = await findLastMessage();
|
||||
|
||||
if (!message) {
|
||||
throw new Error('Message not found');
|
||||
}
|
||||
|
||||
return message;
|
||||
};
|
||||
|
||||
const canUpdateMessage = async (message: IMessage): Promise<boolean> => {
|
||||
if (MessageTypes.isSystemMessage(message)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const canEditMessage = hasAtLeastOnePermission('edit-message', message.rid);
|
||||
const editAllowed = settings.peek('Message_AllowEditing') ?? false;
|
||||
const editOwn = message?.u && message.u._id === getUserId();
|
||||
|
||||
if (!canEditMessage && (!editAllowed || !editOwn)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const blockEditInMinutes = settings.peek('Message_AllowEditing_BlockEditInMinutes');
|
||||
const bypassBlockTimeLimit = hasPermission('bypass-time-limit-edit-and-delete', message.rid);
|
||||
|
||||
const elapsedMinutes = differenceInMinutes(new Date(), message.ts);
|
||||
if (!bypassBlockTimeLimit && elapsedMinutes && blockEditInMinutes && elapsedMinutes > blockEditInMinutes) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const findPreviousOwnMessage = async (message?: IMessage): Promise<IMessage | undefined> => {
|
||||
const uid = getUserId();
|
||||
|
||||
if (!uid) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const msg = Messages.state.findFirst(
|
||||
(record) =>
|
||||
record.rid === rid &&
|
||||
(tmid ? record.tmid === tmid : !record.tmid) &&
|
||||
record.u._id === uid &&
|
||||
record._hidden !== true &&
|
||||
record.ts.getTime() < (message?.ts.getTime() ?? Date.now()),
|
||||
(a, b) => b.ts.getTime() - a.ts.getTime(),
|
||||
);
|
||||
|
||||
if (!msg) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (await canUpdateMessage(msg)) {
|
||||
return msg;
|
||||
}
|
||||
|
||||
return findPreviousOwnMessage(msg);
|
||||
};
|
||||
|
||||
const getPreviousOwnMessage = async (message: IMessage): Promise<IMessage> => {
|
||||
const previousMessage = await findPreviousOwnMessage(message);
|
||||
|
||||
if (!previousMessage) {
|
||||
throw new Error('Message not found');
|
||||
}
|
||||
|
||||
return previousMessage;
|
||||
};
|
||||
|
||||
const findNextOwnMessage = async (message: IMessage): Promise<IMessage | undefined> => {
|
||||
const uid = getUserId();
|
||||
|
||||
if (!uid) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const msg = Messages.state.findFirst(
|
||||
(record) =>
|
||||
record.rid === rid &&
|
||||
(tmid ? record.tmid === tmid : !record.tmid) &&
|
||||
record.u._id === uid &&
|
||||
record._hidden !== true &&
|
||||
record.ts.getTime() > message.ts.getTime(),
|
||||
(a, b) => a.ts.getTime() - b.ts.getTime(),
|
||||
);
|
||||
|
||||
if (!msg) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (await canUpdateMessage(msg)) {
|
||||
return msg;
|
||||
}
|
||||
|
||||
return findNextOwnMessage(msg);
|
||||
};
|
||||
|
||||
const getNextOwnMessage = async (message: IMessage): Promise<IMessage> => {
|
||||
const nextMessage = await findNextOwnMessage(message);
|
||||
|
||||
if (!nextMessage) {
|
||||
throw new Error('Message not found');
|
||||
}
|
||||
|
||||
return nextMessage;
|
||||
};
|
||||
|
||||
const pushEphemeralMessage = async (message: Omit<IMessage, 'rid' | 'tmid'>): Promise<void> => {
|
||||
const fullMessage = { ...message, rid, ...(tmid && { tmid }) };
|
||||
Messages.state.store(fullMessage);
|
||||
|
||||
if (tmid) {
|
||||
upsertThreadMessageInCache(fullMessage, rid, tmid);
|
||||
}
|
||||
};
|
||||
|
||||
const updateMessage = async (message: IEditedMessage, previewUrls?: string[]): Promise<void> => {
|
||||
const params = isEncryptedMessageContent(message)
|
||||
? {
|
||||
msgId: message._id,
|
||||
roomId: message.rid,
|
||||
content: message.content,
|
||||
e2eMentions: message.e2eMentions,
|
||||
}
|
||||
: {
|
||||
previewUrls,
|
||||
msgId: message._id,
|
||||
roomId: message.rid,
|
||||
customFields: message.customFields,
|
||||
text: message.msg,
|
||||
};
|
||||
|
||||
await sdk.rest.post('/v1/chat.update', params);
|
||||
};
|
||||
|
||||
const canDeleteMessage = async (message: IMessage): Promise<boolean> => {
|
||||
const uid = getUserId();
|
||||
|
||||
if (!uid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (MessageTypes.isSystemMessage(message)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const forceDeleteAllowed = hasPermission('force-delete-message', message.rid);
|
||||
if (forceDeleteAllowed) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const deletionEnabled = settings.peek('Message_AllowDeleting');
|
||||
if (!deletionEnabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const deleteAnyAllowed = hasPermission('delete-message', rid);
|
||||
const deleteOwnAllowed = hasPermission('delete-own-message');
|
||||
const deleteAllowed = deleteAnyAllowed || (deleteOwnAllowed && message?.u && message.u._id === getUserId());
|
||||
|
||||
if (!deleteAllowed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const blockDeleteInMinutes = settings.peek('Message_AllowDeleting_BlockDeleteInMinutes');
|
||||
const bypassBlockTimeLimit = hasPermission('bypass-time-limit-edit-and-delete', message.rid);
|
||||
const elapsedMinutes = differenceInMinutes(new Date(), message.ts);
|
||||
const onTimeForDelete = bypassBlockTimeLimit || !blockDeleteInMinutes || !elapsedMinutes || elapsedMinutes <= blockDeleteInMinutes;
|
||||
|
||||
return deleteAllowed && onTimeForDelete;
|
||||
};
|
||||
|
||||
const deleteMessage = async (msgIdOrMsg: IMessage | IMessage['_id']): Promise<void> => {
|
||||
let msgId: string;
|
||||
let roomId: string;
|
||||
if (typeof msgIdOrMsg === 'string') {
|
||||
msgId = msgIdOrMsg;
|
||||
const msg = await findMessageByID(msgId);
|
||||
if (!msg) {
|
||||
throw new Error('Message not found');
|
||||
}
|
||||
roomId = msg.rid;
|
||||
} else {
|
||||
msgId = msgIdOrMsg._id;
|
||||
roomId = msgIdOrMsg.rid;
|
||||
}
|
||||
|
||||
await sdk.rest.post('/v1/chat.delete', { msgId, roomId });
|
||||
};
|
||||
|
||||
const drafts = new Map<IMessage['_id'] | undefined, string>();
|
||||
|
||||
const getDraft = async (mid: IMessage['_id'] | undefined): Promise<string | undefined> => drafts.get(mid);
|
||||
|
||||
const discardDraft = async (mid: IMessage['_id'] | undefined): Promise<void> => {
|
||||
drafts.delete(mid);
|
||||
};
|
||||
|
||||
const saveDraft = async (mid: IMessage['_id'] | undefined, draft: string): Promise<void> => {
|
||||
drafts.set(mid, draft);
|
||||
};
|
||||
|
||||
const findRoom = async (): Promise<IRoom | undefined> => Rooms.state.get(rid);
|
||||
|
||||
const getRoom = async (): Promise<IRoom> => {
|
||||
const room = await findRoom();
|
||||
|
||||
if (!room) {
|
||||
throw new Error('Room not found');
|
||||
}
|
||||
|
||||
return room;
|
||||
};
|
||||
|
||||
const isSubscribedToRoom = async (): Promise<boolean> => !!Subscriptions.state.find((record) => record.rid === rid);
|
||||
|
||||
const joinRoom = async (): Promise<void> => {
|
||||
await sdk.rest.post('/v1/rooms.join', { roomId: rid });
|
||||
};
|
||||
|
||||
const findDiscussionByID = async (drid: IRoom['_id']): Promise<IRoom | undefined> =>
|
||||
Rooms.state.find((record) => Boolean(record._id === drid && record.prid));
|
||||
|
||||
const getDiscussionByID = async (drid: IRoom['_id']): Promise<IRoom> => {
|
||||
const discussion = await findDiscussionByID(drid);
|
||||
|
||||
if (!discussion) {
|
||||
throw new Error('Discussion not found');
|
||||
}
|
||||
|
||||
return discussion;
|
||||
};
|
||||
|
||||
const createStrictGetter = <TFind extends (...args: any[]) => Promise<any>>(
|
||||
find: TFind,
|
||||
errorMessage: string,
|
||||
): ((...args: Parameters<TFind>) => Promise<Exclude<Awaited<ReturnType<TFind>>, undefined>>) => {
|
||||
return async (...args) => {
|
||||
const result = await find(...args);
|
||||
|
||||
if (!result) {
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
};
|
||||
|
||||
const findSubscription = async (): Promise<ISubscription | undefined> => {
|
||||
return Subscriptions.state.find((record) => record.rid === rid);
|
||||
};
|
||||
|
||||
const getSubscription = createStrictGetter(findSubscription, 'Subscription not found');
|
||||
|
||||
const findSubscriptionFromMessage = async (message: IMessage): Promise<ISubscription | undefined> => {
|
||||
return Subscriptions.state.find((record) => record.rid === message.rid);
|
||||
};
|
||||
|
||||
const getSubscriptionFromMessage = createStrictGetter(findSubscriptionFromMessage, 'Subscription not found');
|
||||
|
||||
return {
|
||||
composeMessage,
|
||||
findMessageByID,
|
||||
getMessageByID,
|
||||
findLastMessage,
|
||||
getLastMessage,
|
||||
findPreviousOwnMessage,
|
||||
getPreviousOwnMessage,
|
||||
findNextOwnMessage,
|
||||
getNextOwnMessage,
|
||||
pushEphemeralMessage,
|
||||
canUpdateMessage,
|
||||
updateMessage,
|
||||
canDeleteMessage,
|
||||
deleteMessage,
|
||||
getDraft,
|
||||
saveDraft,
|
||||
discardDraft,
|
||||
findRoom,
|
||||
getRoom,
|
||||
isSubscribedToRoom,
|
||||
joinRoom,
|
||||
findDiscussionByID,
|
||||
getDiscussionByID,
|
||||
findSubscription,
|
||||
getSubscription,
|
||||
findSubscriptionFromMessage,
|
||||
getSubscriptionFromMessage,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { IMessage } from '@rocket.chat/core-typings';
|
||||
import { clientCallbacks } from '@rocket.chat/ui-client';
|
||||
|
||||
import { Rooms } from '../../../stores';
|
||||
import { getUser } from '../../user';
|
||||
|
||||
export const afterSendMessageCallback = async (message: IMessage, rid: string) => {
|
||||
const user = getUser();
|
||||
const room = Rooms.state.get(rid);
|
||||
|
||||
if (!room || !user) {
|
||||
return;
|
||||
}
|
||||
|
||||
const processedMessage = {
|
||||
...message,
|
||||
u: {
|
||||
_id: user?._id,
|
||||
username: user?.username,
|
||||
name: user?.name || '',
|
||||
},
|
||||
};
|
||||
await clientCallbacks.run('afterSaveMessage', processedMessage, { room, user });
|
||||
};
|
||||
35
apps/meteor/client/lib/chats/flows/processMessageEditing.ts
Normal file
35
apps/meteor/client/lib/chats/flows/processMessageEditing.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { IMessage } from '@rocket.chat/core-typings';
|
||||
import { MessageTypes } from '@rocket.chat/message-types';
|
||||
|
||||
import { dispatchToastMessage } from '../../toast';
|
||||
import type { ChatAPI } from '../ChatAPI';
|
||||
|
||||
export const processMessageEditing = async (
|
||||
chat: ChatAPI,
|
||||
message: Pick<IMessage, '_id' | 't'> & Partial<Omit<IMessage, '_id' | 't'>>,
|
||||
previewUrls?: string[],
|
||||
): Promise<boolean> => {
|
||||
const mid = chat.currentEditingMessage.getMID();
|
||||
if (!mid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (MessageTypes.isSystemMessage(message)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!message.msg && !message.attachments?.length && !message.content) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
chat.composer?.clear();
|
||||
await chat.data.updateMessage({ ...message, _id: mid }, previewUrls);
|
||||
} catch (error) {
|
||||
dispatchToastMessage({ type: 'error', message: error });
|
||||
}
|
||||
|
||||
chat.currentEditingMessage.stop();
|
||||
|
||||
return true;
|
||||
};
|
||||
219
apps/meteor/client/lib/chats/flows/processMessageUploads.ts
Normal file
219
apps/meteor/client/lib/chats/flows/processMessageUploads.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import type { AtLeast, FileAttachmentProps, IE2EEMessage, IMessage, IUploadToConfirm } from '@rocket.chat/core-typings';
|
||||
import { imperativeModal, GenericModal } from '@rocket.chat/ui-client';
|
||||
|
||||
import { t } from '../../../../app/utils/lib/i18n';
|
||||
import { getFileExtension } from '../../../../lib/utils/getFileExtension';
|
||||
import { sdk } from '../../SDKClient';
|
||||
import { e2e } from '../../e2ee/rocketchat.e2e';
|
||||
import type { E2ERoom } from '../../e2ee/rocketchat.e2e.room';
|
||||
import { dispatchToastMessage } from '../../toast';
|
||||
import type { ChatAPI, UploadsAPI } from '../ChatAPI';
|
||||
import { isEncryptedUpload, type EncryptedUpload } from '../Upload';
|
||||
|
||||
const getHeightAndWidthFromDataUrl = (dataURL: string): Promise<{ height: number; width: number }> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
resolve({
|
||||
height: img.height,
|
||||
width: img.width,
|
||||
});
|
||||
};
|
||||
img.onerror = () => {
|
||||
reject(new Error('Failed to load image for dimensions'));
|
||||
};
|
||||
img.src = dataURL;
|
||||
});
|
||||
};
|
||||
|
||||
const getAttachmentForFile = async (fileToUpload: EncryptedUpload): Promise<FileAttachmentProps> => {
|
||||
const attachment: FileAttachmentProps = {
|
||||
title: fileToUpload.file.name,
|
||||
type: 'file',
|
||||
title_link: fileToUpload.url,
|
||||
title_link_download: true,
|
||||
encryption: {
|
||||
key: fileToUpload.encryptedFile.key,
|
||||
iv: fileToUpload.encryptedFile.iv,
|
||||
},
|
||||
hashes: {
|
||||
sha256: fileToUpload.encryptedFile.hash,
|
||||
},
|
||||
fileId: fileToUpload.id,
|
||||
};
|
||||
|
||||
const fileType = fileToUpload.file.type.match(/^(image|audio|video)\/.+/)?.[1] as 'image' | 'audio' | 'video' | undefined;
|
||||
|
||||
if (!fileType) {
|
||||
return {
|
||||
...attachment,
|
||||
size: fileToUpload.file.size,
|
||||
format: getFileExtension(fileToUpload.file.name),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...attachment,
|
||||
[`${fileType}_url`]: fileToUpload.url,
|
||||
[`${fileType}_type`]: fileToUpload.file.type,
|
||||
[`${fileType}_size`]: fileToUpload.file.size,
|
||||
...(fileType === 'image' && {
|
||||
image_dimensions: await getHeightAndWidthFromDataUrl(window.URL.createObjectURL(fileToUpload.file)),
|
||||
image_alt: fileToUpload.altText,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const getEncryptedContent = async (filesToUpload: readonly EncryptedUpload[], e2eRoom: E2ERoom, msg: string) => {
|
||||
const attachments: FileAttachmentProps[] = [];
|
||||
|
||||
const arrayOfFiles = await Promise.all(
|
||||
filesToUpload.map(async (fileToUpload) => {
|
||||
attachments.push(await getAttachmentForFile(fileToUpload));
|
||||
|
||||
const file = {
|
||||
_id: fileToUpload.id,
|
||||
name: fileToUpload.file.name,
|
||||
type: fileToUpload.file.type,
|
||||
size: fileToUpload.file.size,
|
||||
format: getFileExtension(fileToUpload.file.name),
|
||||
};
|
||||
|
||||
return file;
|
||||
}),
|
||||
);
|
||||
|
||||
return e2eRoom.encryptMessageContent({
|
||||
attachments,
|
||||
files: arrayOfFiles,
|
||||
file: arrayOfFiles[0],
|
||||
msg,
|
||||
});
|
||||
};
|
||||
|
||||
async function continueSendingMessage(store: UploadsAPI, message: IMessage) {
|
||||
const { msg, rid, tmid } = message;
|
||||
const e2eRoom = await e2e.getInstanceByRoomId(rid);
|
||||
const shouldConvertSentMessages = await e2eRoom?.shouldConvertSentMessages({ msg });
|
||||
const filesToUpload = store.get();
|
||||
|
||||
const confirmFilesQueue: (IUploadToConfirm & {
|
||||
composedMessage: AtLeast<IMessage, 'msg' | 'tmid' | 't' | 'content'> & {
|
||||
fileName?: string;
|
||||
fileContent?: IE2EEMessage['content'];
|
||||
description?: string;
|
||||
};
|
||||
})[] = [];
|
||||
|
||||
const validFiles = filesToUpload.filter((file) => !file.error);
|
||||
|
||||
for (const upload of validFiles) {
|
||||
if (!upload.url || !upload.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
/**
|
||||
* The first message will keep the composedMessage,
|
||||
* subsequent messages will have a empty text
|
||||
* */
|
||||
const currentMsg = upload === validFiles[0] ? msg : '';
|
||||
|
||||
let content;
|
||||
if (!e2eRoom || !isEncryptedUpload(upload)) {
|
||||
confirmFilesQueue.push({
|
||||
_id: upload.id,
|
||||
name: upload.file.name,
|
||||
composedMessage: { tmid, msg: currentMsg, fileName: upload.file.name, description: upload.altText || undefined },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileContent = await e2eRoom.encryptMessageContent(upload.metadataForEncryption);
|
||||
|
||||
if (shouldConvertSentMessages) {
|
||||
content = await getEncryptedContent([upload], e2eRoom, currentMsg);
|
||||
}
|
||||
|
||||
const composedMessage = {
|
||||
tmid,
|
||||
content,
|
||||
t: 'e2e',
|
||||
msg: '',
|
||||
fileContent,
|
||||
} as const;
|
||||
|
||||
confirmFilesQueue.push({ _id: upload.id, name: upload.file.name, content: fileContent, composedMessage });
|
||||
}
|
||||
|
||||
try {
|
||||
store.setProcessingUploads(true);
|
||||
for (const fileToConfirm of confirmFilesQueue) {
|
||||
await sdk.rest.post(`/v1/rooms.mediaConfirm/${rid}/${fileToConfirm._id}`, fileToConfirm.composedMessage);
|
||||
store.removeUpload(fileToConfirm._id);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
dispatchToastMessage({ type: 'error', message: error });
|
||||
} finally {
|
||||
store.setProcessingUploads(false);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export const processMessageUploads = async (chat: ChatAPI, message: IMessage): Promise<boolean> => {
|
||||
const store = chat.composer?.uploads;
|
||||
|
||||
if (!store) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const filesToUpload = store.get();
|
||||
|
||||
if (filesToUpload.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const failedUploads = filesToUpload.filter((upload) => upload.error);
|
||||
|
||||
if (!failedUploads.length) {
|
||||
return continueSendingMessage(store, message);
|
||||
}
|
||||
|
||||
const allUploadsFailed = failedUploads.length === filesToUpload.length;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
imperativeModal.open({
|
||||
component: GenericModal,
|
||||
props: {
|
||||
variant: 'warning',
|
||||
children: t('__count__files_failed_to_upload', {
|
||||
count: failedUploads.length,
|
||||
...(failedUploads.length === 1 && { name: failedUploads[0].file.name }),
|
||||
}),
|
||||
...(allUploadsFailed && {
|
||||
title: t('Warning'),
|
||||
confirmText: t('Ok'),
|
||||
onConfirm: () => {
|
||||
imperativeModal.close();
|
||||
},
|
||||
}),
|
||||
...(!allUploadsFailed && {
|
||||
title: t('Are_you_sure'),
|
||||
confirmText: t('Send_anyway'),
|
||||
cancelText: t('Cancel'),
|
||||
onConfirm: () => {
|
||||
imperativeModal.close();
|
||||
failedUploads.forEach((upload) => store.removeUpload(upload.id));
|
||||
resolve(continueSendingMessage(store, message));
|
||||
},
|
||||
onCancel: () => {
|
||||
imperativeModal.close();
|
||||
},
|
||||
}),
|
||||
onClose: () => {
|
||||
imperativeModal.close();
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
110
apps/meteor/client/lib/chats/flows/processSetReaction.ts
Normal file
110
apps/meteor/client/lib/chats/flows/processSetReaction.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { IMessage } from '@rocket.chat/core-typings';
|
||||
|
||||
import { Rooms, Subscriptions, Messages } from '../../../stores';
|
||||
import { sdk } from '../../SDKClient';
|
||||
import { emoji } from '../../emoji';
|
||||
import { roomCoordinator } from '../../rooms/roomCoordinator';
|
||||
import { dispatchToastMessage } from '../../toast';
|
||||
import { getUser, getUserId } from '../../user';
|
||||
import type { ChatAPI } from '../ChatAPI';
|
||||
|
||||
const runOptimisticSetReaction = (reaction: string, messageId: IMessage['_id']): void => {
|
||||
if (!getUserId()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const user = getUser();
|
||||
if (!user?.username) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message: IMessage | undefined = Messages.state.get(messageId);
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
|
||||
const room = Rooms.state.get(message.rid);
|
||||
if (!room) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.private) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!emoji.list[reaction]) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (roomCoordinator.readOnly(room, user)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Subscriptions.state.find(({ rid }) => rid === message.rid)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { username } = user;
|
||||
|
||||
Messages.state.update(
|
||||
(record) => record._id === messageId,
|
||||
(record) => {
|
||||
const currentReactions = record.reactions ?? {};
|
||||
const hasReacted = currentReactions[reaction]?.usernames.includes(username) ?? false;
|
||||
|
||||
const reactions: NonNullable<IMessage['reactions']> = {};
|
||||
|
||||
for (const [name, value] of Object.entries(currentReactions)) {
|
||||
if (name !== reaction) {
|
||||
reactions[name] = value;
|
||||
continue;
|
||||
}
|
||||
|
||||
const usernames = hasReacted ? value.usernames.filter((u) => u !== username) : [...value.usernames, username];
|
||||
|
||||
if (usernames.length > 0) {
|
||||
reactions[name] = { ...value, usernames };
|
||||
}
|
||||
}
|
||||
|
||||
if (!currentReactions[reaction]) {
|
||||
reactions[reaction] = { usernames: [username] };
|
||||
}
|
||||
|
||||
if (Object.keys(reactions).length === 0) {
|
||||
const { reactions: _, ...rest } = record;
|
||||
return rest;
|
||||
}
|
||||
|
||||
return { ...record, reactions };
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const processSetReaction = async (chat: ChatAPI, { msg }: Pick<IMessage, 'msg'>): Promise<boolean> => {
|
||||
const match = msg.trim().match(/^\+(:.*?:)$/m);
|
||||
if (!match) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [, reaction] = match;
|
||||
if (!emoji.list[reaction]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lastMessage = await chat.data.findLastMessage();
|
||||
|
||||
if (!lastMessage) {
|
||||
return false;
|
||||
}
|
||||
|
||||
chat.composer?.clear();
|
||||
runOptimisticSetReaction(reaction, lastMessage._id);
|
||||
try {
|
||||
await sdk.rest.post('/v1/chat.react', { emoji: reaction, messageId: lastMessage._id });
|
||||
} catch (error) {
|
||||
dispatchToastMessage({ type: 'error', message: error });
|
||||
throw error;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
117
apps/meteor/client/lib/chats/flows/processSlashCommand.ts
Normal file
117
apps/meteor/client/lib/chats/flows/processSlashCommand.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import type { IMessage, SlashCommand } from '@rocket.chat/core-typings';
|
||||
import { Random } from '@rocket.chat/random';
|
||||
import { escapeHTML } from '@rocket.chat/tools';
|
||||
|
||||
import { t } from '../../../../app/utils/lib/i18n';
|
||||
import { sdk } from '../../SDKClient';
|
||||
import { hasAtLeastOnePermission } from '../../authorization';
|
||||
import { settings } from '../../settings';
|
||||
import { slashCommands } from '../../slashCommand';
|
||||
import type { ChatAPI } from '../ChatAPI';
|
||||
|
||||
const parse = (msg: string): { command: string; params: string } | { command: SlashCommand; params: string } | undefined => {
|
||||
const match = msg.match(/^\/([^\s]+)(.*)/);
|
||||
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const [, cmd, params] = match;
|
||||
const command = slashCommands.commands[cmd];
|
||||
|
||||
if (!command) {
|
||||
return { command: cmd, params };
|
||||
}
|
||||
|
||||
return { command, params };
|
||||
};
|
||||
|
||||
const warnUnrecognizedSlashCommand = async (chat: ChatAPI, message: string): Promise<void> => {
|
||||
console.error(message);
|
||||
|
||||
await chat.data.pushEphemeralMessage({
|
||||
_id: Random.id(),
|
||||
ts: new Date(),
|
||||
msg: message,
|
||||
u: {
|
||||
_id: 'rocket.cat',
|
||||
username: 'rocket.cat',
|
||||
name: 'Rocket.Cat',
|
||||
},
|
||||
private: true,
|
||||
_updatedAt: new Date(),
|
||||
});
|
||||
};
|
||||
|
||||
export const processSlashCommand = async (chat: ChatAPI, message: IMessage): Promise<boolean> => {
|
||||
const match = parse(message.msg);
|
||||
|
||||
if (!match) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { command, params } = match;
|
||||
|
||||
if (typeof command === 'string') {
|
||||
if (!settings.peek('Message_AllowUnrecognizedSlashCommand')) {
|
||||
chat.composer?.clear();
|
||||
await warnUnrecognizedSlashCommand(chat, t('No_such_command', { command: escapeHTML(command) }));
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const { permission, clientOnly, callback: handleOnClient, result: handleResult, appId, command: commandName } = command;
|
||||
|
||||
if (permission && !hasAtLeastOnePermission(permission, message.rid)) {
|
||||
chat.composer?.clear();
|
||||
await warnUnrecognizedSlashCommand(chat, t('You_do_not_have_permission_to_execute_this_command', { command: escapeHTML(commandName) }));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (clientOnly && chat.uid) {
|
||||
chat.composer?.clear();
|
||||
handleOnClient?.({ command: commandName, message, params, userId: chat.uid });
|
||||
return true;
|
||||
}
|
||||
|
||||
void sdk.rest.post('/v1/statistics.telemetry', {
|
||||
params: [{ eventName: 'slashCommandsStats', timestamp: Date.now(), command: commandName }],
|
||||
});
|
||||
|
||||
const triggerId = chat.ActionManager.generateTriggerId(appId);
|
||||
|
||||
const data = {
|
||||
cmd: commandName,
|
||||
params,
|
||||
msg: message,
|
||||
userId: chat.uid,
|
||||
} as const;
|
||||
|
||||
try {
|
||||
if (appId) {
|
||||
chat.ActionManager.notifyBusy();
|
||||
}
|
||||
|
||||
chat.composer?.clear();
|
||||
const { result } = await sdk.rest.post('/v1/commands.run', {
|
||||
command: commandName,
|
||||
params,
|
||||
roomId: message.rid,
|
||||
...(message.tmid && { tmid: message.tmid }),
|
||||
...(triggerId && { triggerId }),
|
||||
});
|
||||
|
||||
handleResult?.(undefined, result, data);
|
||||
} catch (error: unknown) {
|
||||
await warnUnrecognizedSlashCommand(chat, t('Something_went_wrong_while_executing_command', { command: commandName }));
|
||||
handleResult?.(error, undefined, data);
|
||||
}
|
||||
|
||||
if (appId) {
|
||||
chat.ActionManager.notifyIdle();
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
61
apps/meteor/client/lib/chats/flows/processTooLongMessage.ts
Normal file
61
apps/meteor/client/lib/chats/flows/processTooLongMessage.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { IMessage } from '@rocket.chat/core-typings';
|
||||
import { GenericModal, imperativeModal } from '@rocket.chat/ui-client';
|
||||
|
||||
import { t } from '../../../../app/utils/lib/i18n';
|
||||
import { settings } from '../../settings';
|
||||
import { dispatchToastMessage } from '../../toast';
|
||||
import { getUser } from '../../user';
|
||||
import type { ChatAPI } from '../ChatAPI';
|
||||
|
||||
export const processTooLongMessage = async (chat: ChatAPI, { msg }: Pick<IMessage, 'msg'>): Promise<boolean> => {
|
||||
const maxAllowedSize = settings.peek('Message_MaxAllowedSize');
|
||||
|
||||
if (msg.length <= maxAllowedSize) {
|
||||
return false;
|
||||
}
|
||||
const fileUploadsEnabled = settings.peek('FileUpload_Enabled');
|
||||
const convertLongMessagesToAttachment = settings.peek('Message_AllowConvertLongMessagesToAttachment');
|
||||
|
||||
if (chat.currentEditingMessage.getMID() || !fileUploadsEnabled || !convertLongMessagesToAttachment) {
|
||||
dispatchToastMessage({ type: 'error', message: new Error(t('Message_too_long')) });
|
||||
chat.composer?.setText(msg);
|
||||
return true;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const onConfirm = async (): Promise<void> => {
|
||||
const contentType = 'text/plain';
|
||||
const messageBlob = new Blob([msg], { type: contentType });
|
||||
const fileName = `${getUser()?.username ?? 'anonymous'} - ${new Date()}.txt`; // TODO: proper naming and formatting
|
||||
const file = new File([messageBlob], fileName, {
|
||||
type: contentType,
|
||||
lastModified: Date.now(),
|
||||
});
|
||||
|
||||
chat.composer?.clear();
|
||||
imperativeModal.close();
|
||||
await chat.flows.uploadFiles({ files: [file] });
|
||||
|
||||
resolve();
|
||||
};
|
||||
|
||||
const onClose = (): void => {
|
||||
imperativeModal.close();
|
||||
resolve();
|
||||
};
|
||||
|
||||
imperativeModal.open({
|
||||
component: GenericModal,
|
||||
props: {
|
||||
title: t('Message_too_long'),
|
||||
children: t('Send_it_as_attachment_instead_question'),
|
||||
onConfirm,
|
||||
onClose,
|
||||
onCancel: onClose,
|
||||
variant: 'warning',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return true;
|
||||
};
|
||||
59
apps/meteor/client/lib/chats/flows/replyBroadcast.spec.ts
Normal file
59
apps/meteor/client/lib/chats/flows/replyBroadcast.spec.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { replyBroadcast } from './replyBroadcast';
|
||||
import { createFakeMessage } from '../../../../tests/mocks/data';
|
||||
import { router } from '../../../providers/RouterProvider';
|
||||
import { roomCoordinator } from '../../rooms/roomCoordinator';
|
||||
import type { ChatAPI } from '../ChatAPI';
|
||||
|
||||
jest.mock('../../rooms/roomCoordinator', () => ({
|
||||
roomCoordinator: {
|
||||
openRouteLink: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../../../providers/RouterProvider', () => ({
|
||||
router: {
|
||||
getSearchParameters: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockedOpenRouteLink = jest.mocked(roomCoordinator.openRouteLink);
|
||||
const mockedGetSearchParameters = jest.mocked(router.getSearchParameters);
|
||||
|
||||
const messageAuthor = {
|
||||
_id: 'author-user-id',
|
||||
username: 'authoruser',
|
||||
name: 'Author User',
|
||||
};
|
||||
|
||||
const message = createFakeMessage({
|
||||
_id: 'reply-message-id',
|
||||
u: messageAuthor,
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('replyBroadcast', () => {
|
||||
it('should not carry over the msg search parameter when opening a direct message', async () => {
|
||||
mockedGetSearchParameters.mockReturnValue({
|
||||
msg: 'stale-message-id',
|
||||
layout: 'embedded',
|
||||
});
|
||||
|
||||
await replyBroadcast({} as ChatAPI, message);
|
||||
|
||||
expect(mockedGetSearchParameters).toHaveBeenCalled();
|
||||
expect(mockedOpenRouteLink).toHaveBeenCalledWith(
|
||||
'd',
|
||||
{ name: messageAuthor.username },
|
||||
{
|
||||
layout: 'embedded',
|
||||
reply: 'reply-message-id',
|
||||
},
|
||||
);
|
||||
|
||||
const searchParams = mockedOpenRouteLink.mock.calls[0]?.[2];
|
||||
expect(searchParams).not.toHaveProperty('msg');
|
||||
});
|
||||
});
|
||||
17
apps/meteor/client/lib/chats/flows/replyBroadcast.ts
Normal file
17
apps/meteor/client/lib/chats/flows/replyBroadcast.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { IMessage } from '@rocket.chat/core-typings';
|
||||
|
||||
import { router } from '../../../providers/RouterProvider';
|
||||
import { roomCoordinator } from '../../rooms/roomCoordinator';
|
||||
import type { ChatAPI } from '../ChatAPI';
|
||||
|
||||
export const replyBroadcast = async (_chat: ChatAPI, message: IMessage) => {
|
||||
const { msg: _, ...searchParameters } = router.getSearchParameters();
|
||||
roomCoordinator.openRouteLink(
|
||||
'd',
|
||||
{ name: message.u.username },
|
||||
{
|
||||
...searchParameters,
|
||||
reply: message._id,
|
||||
},
|
||||
);
|
||||
};
|
||||
43
apps/meteor/client/lib/chats/flows/requestMessageDeletion.ts
Normal file
43
apps/meteor/client/lib/chats/flows/requestMessageDeletion.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { IMessage } from '@rocket.chat/core-typings';
|
||||
import { imperativeModal } from '@rocket.chat/ui-client';
|
||||
|
||||
import { t } from '../../../../app/utils/lib/i18n';
|
||||
import DeleteMessageConfirmModal from '../../../views/room/modals/DeleteMessageConfirmModal';
|
||||
import { dispatchToastMessage } from '../../toast';
|
||||
import type { ChatAPI } from '../ChatAPI';
|
||||
|
||||
export const requestMessageDeletion = async (chat: ChatAPI, message: IMessage): Promise<void> => {
|
||||
if (!(await chat.data.canDeleteMessage(message))) {
|
||||
dispatchToastMessage({ type: 'error', message: t('Message_deleting_blocked') });
|
||||
return;
|
||||
}
|
||||
|
||||
const room = message.drid ? await chat.data.getDiscussionByID(message.drid) : undefined;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const mid = chat.currentEditingMessage.getMID();
|
||||
|
||||
const onCloseModal = async (): Promise<void> => {
|
||||
imperativeModal.close();
|
||||
|
||||
if (mid === message._id) {
|
||||
chat.currentEditingMessage.stop();
|
||||
}
|
||||
chat.composer?.focus();
|
||||
|
||||
resolve();
|
||||
};
|
||||
|
||||
imperativeModal.open({
|
||||
component: DeleteMessageConfirmModal,
|
||||
props: {
|
||||
room,
|
||||
chat,
|
||||
resolve,
|
||||
reject,
|
||||
message,
|
||||
onCancel: onCloseModal,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
195
apps/meteor/client/lib/chats/flows/sendMessage.ts
Normal file
195
apps/meteor/client/lib/chats/flows/sendMessage.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import type { IMessage } from '@rocket.chat/core-typings';
|
||||
|
||||
import { t } from '../../../../app/utils/lib/i18n';
|
||||
import { closeUnclosedCodeBlock } from '../../../../lib/utils/closeUnclosedCodeBlock';
|
||||
import { Messages, Rooms } from '../../../stores';
|
||||
import { onClientBeforeSendMessage } from '../../onClientBeforeSendMessage';
|
||||
import { dispatchToastMessage } from '../../toast';
|
||||
import type { ChatAPI } from '../ChatAPI';
|
||||
import { afterSendMessageCallback } from './afterSendMessageCallback';
|
||||
import { processMessageEditing } from './processMessageEditing';
|
||||
import { processMessageUploads } from './processMessageUploads';
|
||||
import { processSetReaction } from './processSetReaction';
|
||||
import { processSlashCommand } from './processSlashCommand';
|
||||
import { processTooLongMessage } from './processTooLongMessage';
|
||||
import { trim } from '../../../../lib/utils/stringUtils';
|
||||
import { sdk } from '../../SDKClient';
|
||||
import { onClientMessageReceived } from '../../onClientMessageReceived';
|
||||
import { settings } from '../../settings';
|
||||
import { getUserId, getUser } from '../../user';
|
||||
import { upsertThreadMessageInCache } from '../../utils/threadMessageUtils';
|
||||
|
||||
const runOptimisticSendMessage = async (message: Partial<IMessage> & { rid: IMessage['rid']; msg: IMessage['msg'] }): Promise<void> => {
|
||||
const uid = getUserId();
|
||||
if (!uid || trim(message.msg) === '') {
|
||||
return;
|
||||
}
|
||||
const messageAlreadyExists = message._id && Messages.state.get(message._id);
|
||||
if (messageAlreadyExists) {
|
||||
dispatchToastMessage({ type: 'error', message: t('Message_Already_Sent') });
|
||||
return;
|
||||
}
|
||||
const user = getUser();
|
||||
if (!user?.username) {
|
||||
return;
|
||||
}
|
||||
|
||||
const room = Rooms.state.get(message.rid);
|
||||
if (room?.federated) {
|
||||
return;
|
||||
}
|
||||
|
||||
const optimistic: IMessage = {
|
||||
...(message as IMessage),
|
||||
ts: new Date(),
|
||||
u: {
|
||||
_id: uid,
|
||||
username: user.username,
|
||||
name: user.name || '',
|
||||
},
|
||||
temp: true,
|
||||
...(settings.peek('Message_Read_Receipt_Enabled') ? { unread: true } : {}),
|
||||
};
|
||||
|
||||
const processed = await onClientMessageReceived(optimistic);
|
||||
Messages.state.store(processed);
|
||||
|
||||
if (processed.tmid) {
|
||||
upsertThreadMessageInCache(processed, processed.rid, processed.tmid);
|
||||
}
|
||||
};
|
||||
|
||||
const process = async (chat: ChatAPI, message: IMessage, previewUrls?: string[], isSlashCommandAllowed?: boolean): Promise<void> => {
|
||||
const mid = chat.currentEditingMessage.getMID();
|
||||
|
||||
if (await processSetReaction(chat, message)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (await processTooLongMessage(chat, message)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSlashCommandAllowed && (await processSlashCommand(chat, message))) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (await processMessageUploads(chat, message)) {
|
||||
chat.composer?.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
message = (await onClientBeforeSendMessage({ ...message, isEditing: !!mid })) as IMessage & { isEditing?: boolean };
|
||||
|
||||
// e2e should be a client property only
|
||||
delete message.e2e;
|
||||
delete (message as IMessage & { isEditing?: boolean }).isEditing;
|
||||
|
||||
if (await processMessageEditing(chat, message, previewUrls)) {
|
||||
return;
|
||||
}
|
||||
|
||||
chat.composer?.clear();
|
||||
await runOptimisticSendMessage(message);
|
||||
|
||||
await sdk.rest.post('/v1/chat.sendMessage', { message, previewUrls });
|
||||
|
||||
// Clear the optimistic `temp` flag only if the messages stream hasn't already
|
||||
// replaced the record. Overwriting with the server response can clobber stream
|
||||
// updates that arrive first — e.g. read-receipt-driven `unread: false`, async
|
||||
// URL/quote attachments, or E2EE decrypt — leading to stale UI state.
|
||||
Messages.state.update(
|
||||
(record) => record._id === message._id && record.temp === true,
|
||||
({ temp: _, ...record }) => record,
|
||||
);
|
||||
};
|
||||
|
||||
export const sendMessage = async (
|
||||
chat: ChatAPI,
|
||||
{
|
||||
text,
|
||||
tshow,
|
||||
previewUrls,
|
||||
isSlashCommandAllowed,
|
||||
}: { text: string; tshow?: boolean; previewUrls?: string[]; isSlashCommandAllowed?: boolean; tmid?: IMessage['tmid'] },
|
||||
): Promise<boolean> => {
|
||||
if (!(await chat.data.isSubscribedToRoom())) {
|
||||
try {
|
||||
await chat.data.joinRoom();
|
||||
} catch (error) {
|
||||
dispatchToastMessage({ type: 'error', message: error });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
chat.readStateManager.clearUnreadMark();
|
||||
|
||||
const uploadsStore = chat.composer?.uploads;
|
||||
|
||||
text = text.trim();
|
||||
text = closeUnclosedCodeBlock(text);
|
||||
const mid = chat.currentEditingMessage.getMID();
|
||||
|
||||
const hasFiles = uploadsStore && uploadsStore.get().length > 0;
|
||||
if (!text && !mid && !hasFiles) {
|
||||
// Nothing to do
|
||||
return false;
|
||||
}
|
||||
|
||||
if (text || hasFiles) {
|
||||
const message = await chat.data.composeMessage(text, {
|
||||
sendToChannel: tshow,
|
||||
quotedMessages: chat.composer?.quotedMessages.get() ?? [],
|
||||
originalMessage: mid ? await chat.data.findMessageByID(mid) : null,
|
||||
});
|
||||
|
||||
// When editing an encrypted message with files, preserve the original attachments/files
|
||||
// This ensures they're included in the re-encryption process
|
||||
if (mid) {
|
||||
const originalMessage = await chat.data.findMessageByID(mid);
|
||||
|
||||
if (originalMessage?.t === 'e2e' && originalMessage.attachments && originalMessage.attachments.length > 0) {
|
||||
message.attachments = originalMessage.attachments;
|
||||
message.file = originalMessage.file;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Dismiss quoted messages optimistically — they are already baked into
|
||||
// `message` by composeMessage above, so the composer preview must unmount
|
||||
// regardless of whether the send request resolves. Keeping it coupled to a
|
||||
// resolved request leaves the quote stuck in the composer when the REST call
|
||||
// rejects even though the message was already broadcast over the stream.
|
||||
chat.composer?.dismissAllQuotedMessages();
|
||||
await process(chat, message, previewUrls, isSlashCommandAllowed);
|
||||
await afterSendMessageCallback(message, message.rid);
|
||||
} catch (error) {
|
||||
dispatchToastMessage({ type: 'error', message: error });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (mid) {
|
||||
const originalMessage = await chat.data.findMessageByID(mid);
|
||||
|
||||
if (!originalMessage) {
|
||||
dispatchToastMessage({ type: 'warning', message: t('Message_not_found') });
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
if (await chat.flows.processMessageEditing({ ...originalMessage, msg: '' }, previewUrls)) {
|
||||
chat.currentEditingMessage.stop();
|
||||
return false;
|
||||
}
|
||||
|
||||
await chat.currentEditingMessage.reset();
|
||||
await chat.flows.requestMessageDeletion(originalMessage);
|
||||
return false;
|
||||
} catch (error) {
|
||||
dispatchToastMessage({ type: 'error', message: error });
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
86
apps/meteor/client/lib/chats/flows/uploadFiles.ts
Normal file
86
apps/meteor/client/lib/chats/flows/uploadFiles.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { t } from '../../../../app/utils/lib/i18n';
|
||||
import { MAX_MULTIPLE_UPLOADED_FILES } from '../../../../lib/constants';
|
||||
import { e2e } from '../../e2ee';
|
||||
import { settings } from '../../settings';
|
||||
import { dispatchToastMessage } from '../../toast';
|
||||
import type { ChatAPI } from '../ChatAPI';
|
||||
|
||||
export const uploadFiles = async (
|
||||
chat: ChatAPI,
|
||||
{ files, resetFileInput }: { files: readonly File[]; resetFileInput?: () => void },
|
||||
): Promise<void> => {
|
||||
const uploadsStore = chat.composer?.uploads;
|
||||
if (!uploadsStore) {
|
||||
throw new Error('No uploads store found in composer');
|
||||
}
|
||||
|
||||
const mergedFilesLength = files.length + uploadsStore.get().length;
|
||||
if (mergedFilesLength > MAX_MULTIPLE_UPLOADED_FILES) {
|
||||
return dispatchToastMessage({
|
||||
type: 'error',
|
||||
message: t('You_cant_upload_more_than__count__files', { count: MAX_MULTIPLE_UPLOADED_FILES }),
|
||||
});
|
||||
}
|
||||
|
||||
const room = await chat.data.getRoom();
|
||||
|
||||
if (room.encrypted && !settings.peek('E2E_Allow_Unencrypted_Messages') && !settings.peek('E2E_Enable_Encrypt_Files')) {
|
||||
return dispatchToastMessage({
|
||||
type: 'error',
|
||||
message: t('You_cant_send_unencrypted_files_in_an_encrypted_room'),
|
||||
});
|
||||
}
|
||||
|
||||
const uploadFile = async (file: File) => {
|
||||
Object.defineProperty(file, 'name', {
|
||||
writable: true,
|
||||
value: file.name,
|
||||
});
|
||||
|
||||
const e2eRoom = await e2e.getInstanceByRoomId(room._id);
|
||||
|
||||
if (!e2eRoom || !settings.peek('E2E_Enable_Encrypt_Files')) {
|
||||
await uploadsStore.send(file);
|
||||
return;
|
||||
}
|
||||
|
||||
const encryptedFile = await e2eRoom.encryptFile(file);
|
||||
|
||||
if (!e2eRoom.isReady() || !encryptedFile) {
|
||||
dispatchToastMessage({
|
||||
type: 'error',
|
||||
message: t('Error_encrypting_file'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const fileContentData = {
|
||||
type: file.type,
|
||||
typeGroup: file.type.split('/')[0],
|
||||
name: file.name,
|
||||
encryption: {
|
||||
key: encryptedFile.key,
|
||||
iv: encryptedFile.iv,
|
||||
},
|
||||
hashes: {
|
||||
sha256: encryptedFile.hash,
|
||||
},
|
||||
};
|
||||
|
||||
const fileContent = {
|
||||
raw: fileContentData,
|
||||
encrypted: await e2eRoom.encryptMessageContent(fileContentData),
|
||||
};
|
||||
|
||||
await uploadsStore.send(encryptedFile.file, { rawFile: file, fileContent, encryptedFile });
|
||||
};
|
||||
|
||||
resetFileInput?.();
|
||||
chat?.action.performContinuously('uploading');
|
||||
|
||||
try {
|
||||
await Promise.allSettled(files.map((file) => uploadFile(file)));
|
||||
} finally {
|
||||
chat.composer?.focus();
|
||||
}
|
||||
};
|
||||
159
apps/meteor/client/lib/chats/readStateManager.ts
Normal file
159
apps/meteor/client/lib/chats/readStateManager.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import type { IMessage, IRoom, ISubscription } from '@rocket.chat/core-typings';
|
||||
import { Emitter } from '@rocket.chat/emitter';
|
||||
|
||||
import { withDebouncing } from '../../../lib/utils/highOrderFunctions';
|
||||
import { Messages } from '../../stores';
|
||||
import { LegacyRoomManager } from '../LegacyRoomManager';
|
||||
import { RoomHistoryManager } from '../RoomHistoryManager';
|
||||
import { sdk } from '../SDKClient';
|
||||
import { getUserId } from '../user';
|
||||
|
||||
export class ReadStateManager extends Emitter {
|
||||
private rid: IRoom['_id'];
|
||||
|
||||
private firstUnreadRecordId?: IMessage['_id'];
|
||||
|
||||
private subscription?: ISubscription;
|
||||
|
||||
public constructor(rid: IRoom['_id']) {
|
||||
super();
|
||||
this.rid = rid;
|
||||
}
|
||||
|
||||
public getRid() {
|
||||
return this.rid;
|
||||
}
|
||||
|
||||
public onUnreadStateChange = (callback: () => void): (() => void) => {
|
||||
return this.on('unread-state-change', callback);
|
||||
};
|
||||
|
||||
public getFirstUnreadRecordId = () => {
|
||||
return this.firstUnreadRecordId;
|
||||
};
|
||||
|
||||
public subscribeToMessages() {
|
||||
return RoomHistoryManager.on('loaded-messages', () => this.updateFirstUnreadRecordId());
|
||||
}
|
||||
|
||||
public updateSubscription(subscription?: ISubscription) {
|
||||
if (!subscription) {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstUpdate = !this.subscription;
|
||||
|
||||
this.subscription = subscription;
|
||||
LegacyRoomManager.setPropertyByRid(this.rid, 'unreadSince', this.subscription.ls);
|
||||
|
||||
const { unread, alert } = this.subscription;
|
||||
if (!unread && !alert) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (firstUpdate) {
|
||||
this.updateFirstUnreadRecordId();
|
||||
return;
|
||||
}
|
||||
|
||||
if (document.hasFocus() && this.firstUnreadRecordId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateFirstUnreadRecordId();
|
||||
}
|
||||
|
||||
private updateFirstUnreadRecordId() {
|
||||
if (!this.subscription?.ls) {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstUnreadRecord = Messages.state.findFirst(
|
||||
(record) =>
|
||||
record.rid === this.subscription?.rid &&
|
||||
record.ts.getTime() > (this.subscription.ls?.getTime() ?? 0) &&
|
||||
record.u._id !== getUserId() &&
|
||||
(!record.tmid || record.tshow === true),
|
||||
(a, b) => a.ts.getTime() - b.ts.getTime(),
|
||||
);
|
||||
|
||||
this.setFirstUnreadRecordId(firstUnreadRecord?._id);
|
||||
}
|
||||
|
||||
private setFirstUnreadRecordId(firstUnreadRecordId: string | undefined) {
|
||||
this.firstUnreadRecordId = firstUnreadRecordId;
|
||||
this.emit('unread-state-change', this.firstUnreadRecordId);
|
||||
}
|
||||
|
||||
public clearUnreadMark() {
|
||||
this.setFirstUnreadRecordId(undefined);
|
||||
}
|
||||
|
||||
public handleWindowEvents = (): (() => void) => {
|
||||
const handleWindowFocus = () => {
|
||||
this.attemptMarkAsRead();
|
||||
};
|
||||
|
||||
const handleWindowKeyUp = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
this.markAsRead();
|
||||
this.updateFirstUnreadRecordId();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('focus', handleWindowFocus);
|
||||
window.addEventListener('keyup', handleWindowKeyUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('focus', handleWindowFocus);
|
||||
window.removeEventListener('keyup', handleWindowKeyUp);
|
||||
};
|
||||
};
|
||||
|
||||
private isUnreadMarkVisible: () => boolean = () => false;
|
||||
|
||||
public setIsUnreadMarkVisibleCallback(callback: () => boolean) {
|
||||
this.isUnreadMarkVisible = callback;
|
||||
}
|
||||
|
||||
// This will only mark as read if the unread mark is visible
|
||||
public attemptMarkAsRead() {
|
||||
const { alert, unread } = this.subscription || {};
|
||||
if (!alert && unread === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!document.hasFocus()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.firstUnreadRecordId && this.isUnreadMarkVisible() === false) {
|
||||
return;
|
||||
}
|
||||
// if there are unloaded unread messages, don't mark as read
|
||||
if (RoomHistoryManager.getRoom(this.rid).unreadNotLoaded > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.markAsRead();
|
||||
}
|
||||
|
||||
public debouncedMarkAsRead = withDebouncing({ wait: 1000 })(() => {
|
||||
try {
|
||||
return this.markAsRead();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
});
|
||||
|
||||
// this will always mark as read.
|
||||
public async markAsRead() {
|
||||
if (!this.rid || !this.subscription?.rid) {
|
||||
return;
|
||||
}
|
||||
|
||||
return sdk.rest.post('/v1/subscriptions.read', { rid: this.rid }).then(() => {
|
||||
RoomHistoryManager.updateRoom(this.rid, { unreadNotLoaded: 0 });
|
||||
});
|
||||
}
|
||||
}
|
||||
219
apps/meteor/client/lib/chats/uploads.ts
Normal file
219
apps/meteor/client/lib/chats/uploads.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import type { IMessage, IRoom } from '@rocket.chat/core-typings';
|
||||
import { Emitter } from '@rocket.chat/emitter';
|
||||
import { Random } from '@rocket.chat/random';
|
||||
import fileSize from 'filesize';
|
||||
|
||||
import { getErrorMessage } from '../errorHandling';
|
||||
import type { UploadsAPI, EncryptedFileUploadContent } from './ChatAPI';
|
||||
import { isEncryptedUpload, type Upload } from './Upload';
|
||||
import { i18n } from '../../../app/utils/lib/i18n';
|
||||
import { sdk } from '../SDKClient';
|
||||
import { USER_ACTIVITIES, UserAction } from '../UserAction';
|
||||
import { settings } from '../settings';
|
||||
import { fileUploadIsValidContentType } from '../utils/restrictions';
|
||||
|
||||
class UploadsStore extends Emitter<{ update: void; [x: `cancelling-${Upload['id']}`]: void }> implements UploadsAPI {
|
||||
private rid: string;
|
||||
|
||||
private tmid?: string;
|
||||
|
||||
constructor({ rid, tmid }: { rid: IRoom['_id']; tmid?: IMessage['_id'] }) {
|
||||
super();
|
||||
this.rid = rid;
|
||||
this.tmid = tmid;
|
||||
}
|
||||
|
||||
private uploads: readonly Upload[] = [];
|
||||
|
||||
private processingUploads: boolean = false;
|
||||
|
||||
set = (uploads: Upload[]): void => {
|
||||
this.uploads = uploads;
|
||||
this.emit('update');
|
||||
};
|
||||
|
||||
get = (): readonly Upload[] => this.uploads;
|
||||
|
||||
subscribe = (callback: () => void): (() => void) => this.on('update', callback);
|
||||
|
||||
setProcessingUploads = (processing: boolean): void => {
|
||||
this.processingUploads = processing;
|
||||
this.emit('update');
|
||||
};
|
||||
|
||||
getProcessingUploads = (): boolean => this.processingUploads;
|
||||
|
||||
cancel = (id: Upload['id']): void => {
|
||||
this.emit(`cancelling-${id}`);
|
||||
};
|
||||
|
||||
wipeFailedOnes = (): void => {
|
||||
this.set(this.uploads.filter((upload) => !upload.error));
|
||||
};
|
||||
|
||||
private updateUpload(id: Upload['id'], patch: Partial<Upload>): void {
|
||||
this.set(this.uploads.map((upload) => (upload.id !== id ? upload : { ...upload, ...patch })));
|
||||
}
|
||||
|
||||
removeUpload = (id: Upload['id']): void => {
|
||||
this.set(this.uploads.filter((upload) => upload.id !== id));
|
||||
|
||||
if (this.uploads.length === 0) {
|
||||
UserAction.stop(this.rid, USER_ACTIVITIES.USER_UPLOADING, { tmid: this.tmid });
|
||||
}
|
||||
};
|
||||
|
||||
editUploadAltText = (uploadId: Upload['id'], altText: string) => {
|
||||
this.set(
|
||||
this.uploads.map((upload) => {
|
||||
if (upload.id !== uploadId) {
|
||||
return upload;
|
||||
}
|
||||
|
||||
return {
|
||||
...upload,
|
||||
altText,
|
||||
...(isEncryptedUpload(upload) && {
|
||||
metadataForEncryption: { ...upload.metadataForEncryption, altText },
|
||||
}),
|
||||
};
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
editUploadFileName = (uploadId: Upload['id'], fileName: Upload['file']['name']) => {
|
||||
try {
|
||||
this.set(
|
||||
this.uploads.map((upload) => {
|
||||
if (upload.id !== uploadId) {
|
||||
return upload;
|
||||
}
|
||||
|
||||
return {
|
||||
...upload,
|
||||
file: new File([upload.file], fileName, upload.file),
|
||||
...(isEncryptedUpload(upload) && {
|
||||
metadataForEncryption: { ...upload.metadataForEncryption, name: fileName },
|
||||
}),
|
||||
};
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
this.set(
|
||||
this.uploads.map((upload) => {
|
||||
if (upload.id !== uploadId) {
|
||||
return upload;
|
||||
}
|
||||
|
||||
return {
|
||||
...upload,
|
||||
percentage: 0,
|
||||
error: new Error(i18n.t('FileUpload_Update_Failed')),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
clear = () => {
|
||||
this.set([]);
|
||||
UserAction.stop(this.rid, USER_ACTIVITIES.USER_UPLOADING, { tmid: this.tmid });
|
||||
};
|
||||
|
||||
async send(file: File, encrypted?: EncryptedFileUploadContent): Promise<void> {
|
||||
const maxFileSize = settings.peek('FileUpload_MaxFileSize');
|
||||
const invalidContentType = !fileUploadIsValidContentType(encrypted ? encrypted.rawFile.type : file.type);
|
||||
const id = Random.id();
|
||||
|
||||
this.set([
|
||||
...this.uploads,
|
||||
{
|
||||
id,
|
||||
file: encrypted ? encrypted.rawFile : file,
|
||||
percentage: 0,
|
||||
...(encrypted && {
|
||||
encryptedFile: encrypted.encryptedFile,
|
||||
metadataForEncryption: encrypted.fileContent.raw,
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
if (file.size === 0) {
|
||||
return reject(new Error(i18n.t('FileUpload_File_Empty')));
|
||||
}
|
||||
|
||||
// -1 maxFileSize means there is no limit
|
||||
if (maxFileSize > -1 && (file.size || 0) > maxFileSize) {
|
||||
return reject(new Error(i18n.t('File_exceeds_allowed_size_of_bytes', { size: fileSize(maxFileSize) })));
|
||||
}
|
||||
|
||||
if (invalidContentType) {
|
||||
return reject(new Error(i18n.t('FileUpload_MediaType_NotAccepted__type__', { type: file.type })));
|
||||
}
|
||||
|
||||
const xhr = sdk.rest.upload(
|
||||
`/v1/rooms.media/${this.rid}`,
|
||||
{
|
||||
file,
|
||||
...(encrypted && {
|
||||
content: JSON.stringify(encrypted.fileContent.encrypted),
|
||||
}),
|
||||
},
|
||||
{
|
||||
load: (event) => {
|
||||
resolve(event);
|
||||
},
|
||||
progress: (event) => {
|
||||
if (!event.lengthComputable) {
|
||||
return;
|
||||
}
|
||||
const progress = (event.loaded / event.total) * 100;
|
||||
this.updateUpload(id, { percentage: Math.min(Math.round(progress), 99) || 0 });
|
||||
},
|
||||
error: (event) => {
|
||||
this.updateUpload(id, { percentage: 0, error: new Error(xhr.responseText) });
|
||||
reject(event);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
xhr.onload = () => {
|
||||
try {
|
||||
if (xhr.readyState !== xhr.DONE) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (xhr.status === 400) {
|
||||
const error = JSON.parse(xhr.responseText);
|
||||
this.updateUpload(id, { percentage: 0, error: new Error(error.error) });
|
||||
return;
|
||||
}
|
||||
|
||||
if (xhr.status === 200) {
|
||||
const result = JSON.parse(xhr.responseText);
|
||||
this.updateUpload(id, { id: result.file._id, url: result.file.url, percentage: 100 });
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateUpload(id, { percentage: 0, error: new Error(i18n.t('FileUpload_Error')) });
|
||||
} catch (error) {
|
||||
this.updateUpload(id, { percentage: 0, error: new Error(getErrorMessage(error)) });
|
||||
}
|
||||
};
|
||||
|
||||
this.once(`cancelling-${id}`, () => {
|
||||
xhr.abort();
|
||||
this.removeUpload(id);
|
||||
reject(new Error(i18n.t('FileUpload_Canceled')));
|
||||
});
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
this.updateUpload(id, { percentage: 0, error: new Error(getErrorMessage(error)) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const createUploadsAPI = ({ rid, tmid }: { rid: IRoom['_id']; tmid?: IMessage['_id'] }): UploadsAPI =>
|
||||
new UploadsStore({ rid, tmid });
|
||||
23
apps/meteor/client/lib/clickableItem.tsx
Normal file
23
apps/meteor/client/lib/clickableItem.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { css } from '@rocket.chat/css-in-js';
|
||||
import type { Box } from '@rocket.chat/fuselage';
|
||||
import { Palette } from '@rocket.chat/fuselage';
|
||||
import type { ComponentPropsWithoutRef, ComponentType } from 'react';
|
||||
|
||||
// TODO remove border from here
|
||||
export function clickableItem<TProps extends Pick<ComponentPropsWithoutRef<typeof Box>, 'className' | 'tabIndex'>>(
|
||||
Component: ComponentType<TProps>,
|
||||
) {
|
||||
const clickable = css`
|
||||
cursor: pointer;
|
||||
&:hover,
|
||||
&:focus {
|
||||
background: ${Palette.surface['surface-hover']};
|
||||
}
|
||||
border-bottom: 1px solid ${Palette.stroke['stroke-extra-light']} !important;
|
||||
`;
|
||||
const WrappedComponent = (props: TProps) => <Component className={clickable} tabIndex={0} {...props} />;
|
||||
|
||||
WrappedComponent.displayName = `clickableItem(${Component.displayName ?? Component.name ?? 'Component'})`;
|
||||
|
||||
return WrappedComponent;
|
||||
}
|
||||
157
apps/meteor/client/lib/codeMirror.ts
Normal file
157
apps/meteor/client/lib/codeMirror.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import 'codemirror/addon/fold/brace-fold.js';
|
||||
import 'codemirror/addon/fold/comment-fold.js';
|
||||
import 'codemirror/addon/fold/foldcode.js';
|
||||
import 'codemirror/addon/fold/foldgutter.css';
|
||||
import 'codemirror/addon/fold/foldgutter.js';
|
||||
import 'codemirror/addon/fold/indent-fold.js';
|
||||
import 'codemirror/addon/fold/markdown-fold.js';
|
||||
import 'codemirror/addon/fold/xml-fold.js';
|
||||
|
||||
// lints
|
||||
// import 'codemirror/addon/lint/jsonlint.js';
|
||||
// import 'codemirror/addon/lint/jshint.js';
|
||||
// import 'codemirror/addon/lint/csslint.js';
|
||||
// import 'codemirror/addon/lint/css-lint.js';
|
||||
// import 'codemirror/addon/lint/html-lint.js';
|
||||
// import 'codemirror/addon/lint/javascript-lint.js';
|
||||
// import 'codemirror/addon/lint/json-lint.js';
|
||||
// import 'codemirror/addon/lint/yaml-lint.js';
|
||||
// import 'codemirror/addon/lint/lint.css';
|
||||
// import 'codemirror/addon/lint/lint.js';
|
||||
|
||||
// active line mode
|
||||
import 'codemirror/addon/selection/active-line.js';
|
||||
|
||||
// search/replace
|
||||
import 'codemirror/addon/search/search.js';
|
||||
import 'codemirror/addon/search/searchcursor.js';
|
||||
import 'codemirror/addon/dialog/dialog.js';
|
||||
import 'codemirror/addon/dialog/dialog.css';
|
||||
|
||||
// overlay: required by `gfm.js`
|
||||
import 'codemirror/addon/mode/overlay.js';
|
||||
|
||||
// markdown list continuation; nice complement for gfm
|
||||
import 'codemirror/addon/edit/continuelist.js';
|
||||
|
||||
// modes
|
||||
import 'codemirror/mode/apl/apl.js';
|
||||
import 'codemirror/mode/asterisk/asterisk.js';
|
||||
import 'codemirror/mode/clike/clike.js';
|
||||
import 'codemirror/mode/clojure/clojure.js';
|
||||
import 'codemirror/mode/cobol/cobol.js';
|
||||
import 'codemirror/mode/commonlisp/commonlisp.js';
|
||||
import 'codemirror/mode/coffeescript/coffeescript.js';
|
||||
import 'codemirror/mode/css/css.js';
|
||||
import 'codemirror/mode/cypher/cypher.js';
|
||||
import 'codemirror/mode/d/d.js';
|
||||
import 'codemirror/mode/diff/diff.js';
|
||||
import 'codemirror/mode/django/django.js';
|
||||
import 'codemirror/mode/dockerfile/dockerfile.js';
|
||||
import 'codemirror/mode/dtd/dtd.js';
|
||||
import 'codemirror/mode/dylan/dylan.js';
|
||||
import 'codemirror/mode/ecl/ecl.js';
|
||||
import 'codemirror/mode/eiffel/eiffel.js';
|
||||
import 'codemirror/mode/erlang/erlang.js';
|
||||
import 'codemirror/mode/fortran/fortran.js';
|
||||
import 'codemirror/mode/gas/gas.js';
|
||||
import 'codemirror/mode/gfm/gfm.js';
|
||||
import 'codemirror/mode/gherkin/gherkin.js';
|
||||
import 'codemirror/mode/go/go.js';
|
||||
import 'codemirror/mode/groovy/groovy.js';
|
||||
import 'codemirror/mode/haml/haml.js';
|
||||
import 'codemirror/mode/haskell/haskell.js';
|
||||
import 'codemirror/mode/haxe/haxe.js';
|
||||
import 'codemirror/mode/htmlembedded/htmlembedded.js';
|
||||
import 'codemirror/mode/htmlmixed/htmlmixed.js';
|
||||
import 'codemirror/mode/http/http.js';
|
||||
import 'codemirror/mode/idl/idl.js';
|
||||
import 'codemirror/mode/javascript/javascript.js';
|
||||
import 'codemirror/mode/jinja2/jinja2.js';
|
||||
import 'codemirror/mode/julia/julia.js';
|
||||
import 'codemirror/mode/livescript/livescript.js';
|
||||
import 'codemirror/mode/lua/lua.js';
|
||||
import 'codemirror/mode/markdown/markdown.js';
|
||||
import 'codemirror/mode/mirc/mirc.js';
|
||||
import 'codemirror/mode/mllike/mllike.js';
|
||||
import 'codemirror/mode/modelica/modelica.js';
|
||||
import 'codemirror/mode/nginx/nginx.js';
|
||||
import 'codemirror/mode/ntriples/ntriples.js';
|
||||
import 'codemirror/mode/octave/octave.js';
|
||||
import 'codemirror/mode/pascal/pascal.js';
|
||||
import 'codemirror/mode/pegjs/pegjs.js';
|
||||
import 'codemirror/mode/perl/perl.js';
|
||||
import 'codemirror/mode/php/php.js';
|
||||
import 'codemirror/mode/pig/pig.js';
|
||||
import 'codemirror/mode/properties/properties.js';
|
||||
import 'codemirror/mode/puppet/puppet.js';
|
||||
import 'codemirror/mode/python/python.js';
|
||||
import 'codemirror/mode/q/q.js';
|
||||
import 'codemirror/mode/r/r.js';
|
||||
import 'codemirror/mode/rpm/rpm.js';
|
||||
import 'codemirror/mode/rst/rst.js';
|
||||
import 'codemirror/mode/ruby/ruby.js';
|
||||
import 'codemirror/mode/rust/rust.js';
|
||||
import 'codemirror/mode/sass/sass.js';
|
||||
import 'codemirror/mode/scheme/scheme.js';
|
||||
import 'codemirror/mode/shell/shell.js';
|
||||
import 'codemirror/mode/sieve/sieve.js';
|
||||
import 'codemirror/mode/slim/slim.js';
|
||||
import 'codemirror/mode/smalltalk/smalltalk.js';
|
||||
import 'codemirror/mode/smarty/smarty.js';
|
||||
import 'codemirror/mode/solr/solr.js';
|
||||
import 'codemirror/mode/sparql/sparql.js';
|
||||
import 'codemirror/mode/sql/sql.js';
|
||||
import 'codemirror/mode/stex/stex.js';
|
||||
import 'codemirror/mode/tcl/tcl.js';
|
||||
import 'codemirror/mode/textile/textile.js';
|
||||
import 'codemirror/mode/tiddlywiki/tiddlywiki.js';
|
||||
import 'codemirror/mode/tiki/tiki.js';
|
||||
import 'codemirror/mode/toml/toml.js';
|
||||
import 'codemirror/mode/tornado/tornado.js';
|
||||
import 'codemirror/mode/turtle/turtle.js';
|
||||
import 'codemirror/mode/vb/vb.js';
|
||||
import 'codemirror/mode/vbscript/vbscript.js';
|
||||
import 'codemirror/mode/velocity/velocity.js';
|
||||
import 'codemirror/mode/verilog/verilog.js';
|
||||
import 'codemirror/mode/xml/xml.js';
|
||||
import 'codemirror/mode/xquery/xquery.js';
|
||||
import 'codemirror/mode/yaml/yaml.js';
|
||||
import 'codemirror/mode/z80/z80.js';
|
||||
|
||||
// themes
|
||||
// import 'codemirror/theme/3024-day.css';
|
||||
// import 'codemirror/theme/3024-night.css';
|
||||
// import 'codemirror/theme/ambiance-mobile.css';
|
||||
// import 'codemirror/theme/ambiance.css';
|
||||
// import 'codemirror/theme/base16-dark.css';
|
||||
// import 'codemirror/theme/base16-light.css';
|
||||
// import 'codemirror/theme/blackboard.css';
|
||||
// import 'codemirror/theme/cobalt.css';
|
||||
// import 'codemirror/theme/eclipse.css';
|
||||
// import 'codemirror/theme/elegant.css';
|
||||
// import 'codemirror/theme/erlang-dark.css';
|
||||
// import 'codemirror/theme/lesser-dark.css';
|
||||
// import 'codemirror/theme/mbo.css';
|
||||
// import 'codemirror/theme/mdn-like.css';
|
||||
// import 'codemirror/theme/midnight.css';
|
||||
// import 'codemirror/theme/monokai.css';
|
||||
// import 'codemirror/theme/neat.css';
|
||||
// import 'codemirror/theme/neo.css';
|
||||
// import 'codemirror/theme/night.css';
|
||||
// import 'codemirror/theme/paraiso-dark.css';
|
||||
// import 'codemirror/theme/paraiso-light.css';
|
||||
// import 'codemirror/theme/pastel-on-dark.css';
|
||||
// import 'codemirror/theme/rubyblue.css';
|
||||
// import 'codemirror/theme/solarized.css';
|
||||
// import 'codemirror/theme/the-matrix.css';
|
||||
// import 'codemirror/theme/tomorrow-night-eighties.css';
|
||||
// import 'codemirror/theme/twilight.css';
|
||||
// import 'codemirror/theme/vibrant-ink.css';
|
||||
// import 'codemirror/theme/xq-dark.css';
|
||||
// import 'codemirror/theme/xq-light.css';
|
||||
|
||||
// key bindings
|
||||
// import 'codemirror/keymap/emacs.js';
|
||||
import 'codemirror/keymap/sublime.js';
|
||||
// import 'codemirror/keymap/vim.js';
|
||||
5
apps/meteor/client/lib/constants.ts
Normal file
5
apps/meteor/client/lib/constants.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const USER_STATUS_TEXT_MAX_LENGTH = 120;
|
||||
export const BIO_TEXT_MAX_LENGTH = 260;
|
||||
export const VIDEOCONF_STACK_MAX_USERS = 6;
|
||||
export const NAVIGATION_REGION_ID = 'navigation-region';
|
||||
export const MAX_FILE_SIZE_PREVIEW = 10485760; // 10MB
|
||||
65
apps/meteor/client/lib/convertTimeUnit.spec.ts
Normal file
65
apps/meteor/client/lib/convertTimeUnit.spec.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { TIMEUNIT, timeUnitToMs, msToTimeUnit } from './convertTimeUnit';
|
||||
|
||||
describe('timeUnitToMs function', () => {
|
||||
it('should correctly convert days to milliseconds', () => {
|
||||
expect(timeUnitToMs(TIMEUNIT.days, 1)).toBe(86400000);
|
||||
expect(timeUnitToMs(TIMEUNIT.days, 2)).toBe(172800000);
|
||||
expect(timeUnitToMs(TIMEUNIT.days, 0.5)).toBe(43200000);
|
||||
});
|
||||
|
||||
it('should correctly convert hours to milliseconds', () => {
|
||||
expect(timeUnitToMs(TIMEUNIT.hours, 1)).toBe(3600000);
|
||||
expect(timeUnitToMs(TIMEUNIT.hours, 2)).toBe(7200000);
|
||||
expect(timeUnitToMs(TIMEUNIT.hours, 0.5)).toBe(1800000);
|
||||
});
|
||||
|
||||
it('should correctly convert minutes to milliseconds', () => {
|
||||
expect(timeUnitToMs(TIMEUNIT.minutes, 1)).toBe(60000);
|
||||
expect(timeUnitToMs(TIMEUNIT.minutes, 2)).toBe(120000);
|
||||
expect(timeUnitToMs(TIMEUNIT.minutes, 0.5)).toBe(30000);
|
||||
});
|
||||
|
||||
it('should throw an error for invalid time units', () => {
|
||||
expect(() => timeUnitToMs('invalidUnit' as TIMEUNIT, 1)).toThrow('timeUnitToMs - invalid time unit');
|
||||
});
|
||||
|
||||
it('should throw an error for invalid timespan', () => {
|
||||
const errorMessage = 'timeUnitToMs - invalid timespan';
|
||||
expect(() => timeUnitToMs(TIMEUNIT.days, NaN)).toThrow(errorMessage);
|
||||
expect(() => timeUnitToMs(TIMEUNIT.days, Infinity)).toThrow(errorMessage);
|
||||
expect(() => timeUnitToMs(TIMEUNIT.days, -Infinity)).toThrow(errorMessage);
|
||||
expect(() => timeUnitToMs(TIMEUNIT.days, -1)).toThrow(errorMessage);
|
||||
});
|
||||
});
|
||||
|
||||
describe('msToTimeUnit function', () => {
|
||||
it('should correctly convert milliseconds to days', () => {
|
||||
expect(msToTimeUnit(TIMEUNIT.days, 86400000)).toBe(1); // 1 day
|
||||
expect(msToTimeUnit(TIMEUNIT.days, 172800000)).toBe(2); // 2 days
|
||||
expect(msToTimeUnit(TIMEUNIT.days, 43200000)).toBe(0.5); // .5 days
|
||||
});
|
||||
|
||||
it('should correctly convert milliseconds to hours', () => {
|
||||
expect(msToTimeUnit(TIMEUNIT.hours, 3600000)).toBe(1); // 1 hour
|
||||
expect(msToTimeUnit(TIMEUNIT.hours, 7200000)).toBe(2); // 2 hours
|
||||
expect(msToTimeUnit(TIMEUNIT.hours, 1800000)).toBe(0.5); // .5 hours
|
||||
});
|
||||
|
||||
it('should correctly convert milliseconds to minutes', () => {
|
||||
expect(msToTimeUnit(TIMEUNIT.minutes, 60000)).toBe(1); // 1 min
|
||||
expect(msToTimeUnit(TIMEUNIT.minutes, 120000)).toBe(2); // 2 min
|
||||
expect(msToTimeUnit(TIMEUNIT.minutes, 30000)).toBe(0.5); // .5 min
|
||||
});
|
||||
|
||||
it('should throw an error for invalid time units', () => {
|
||||
expect(() => msToTimeUnit('invalidUnit' as TIMEUNIT, 1)).toThrow('msToTimeUnit - invalid time unit');
|
||||
});
|
||||
|
||||
it('should throw an error for invalid timespan', () => {
|
||||
const errorMessage = 'msToTimeUnit - invalid timespan';
|
||||
expect(() => msToTimeUnit(TIMEUNIT.days, NaN)).toThrow(errorMessage);
|
||||
expect(() => msToTimeUnit(TIMEUNIT.days, Infinity)).toThrow(errorMessage);
|
||||
expect(() => msToTimeUnit(TIMEUNIT.days, -Infinity)).toThrow(errorMessage);
|
||||
expect(() => msToTimeUnit(TIMEUNIT.days, -1)).toThrow(errorMessage);
|
||||
});
|
||||
});
|
||||
58
apps/meteor/client/lib/convertTimeUnit.ts
Normal file
58
apps/meteor/client/lib/convertTimeUnit.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
export enum TIMEUNIT {
|
||||
days = 'days',
|
||||
hours = 'hours',
|
||||
minutes = 'minutes',
|
||||
}
|
||||
|
||||
export const isValidTimespan = (timespan: number): boolean => {
|
||||
if (Number.isNaN(timespan)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Number.isFinite(timespan)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (timespan < 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const timeUnitToMs = (unit: TIMEUNIT, timespan: number) => {
|
||||
if (!isValidTimespan(timespan)) {
|
||||
throw new Error(`timeUnitToMs - invalid timespan:${timespan}`);
|
||||
}
|
||||
|
||||
switch (unit) {
|
||||
case TIMEUNIT.days:
|
||||
return timespan * 24 * 60 * 60 * 1000;
|
||||
|
||||
case TIMEUNIT.hours:
|
||||
return timespan * 60 * 60 * 1000;
|
||||
|
||||
case TIMEUNIT.minutes:
|
||||
return timespan * 60 * 1000;
|
||||
|
||||
default:
|
||||
throw new Error('timeUnitToMs - invalid time unit');
|
||||
}
|
||||
};
|
||||
|
||||
export const msToTimeUnit = (unit: TIMEUNIT, timespan: number) => {
|
||||
if (!isValidTimespan(timespan)) {
|
||||
throw new Error(`msToTimeUnit - invalid timespan:${timespan}`);
|
||||
}
|
||||
|
||||
switch (unit) {
|
||||
case TIMEUNIT.days:
|
||||
return timespan / 24 / 60 / 60 / 1000;
|
||||
case TIMEUNIT.hours:
|
||||
return timespan / 60 / 60 / 1000;
|
||||
case TIMEUNIT.minutes:
|
||||
return timespan / 60 / 1000;
|
||||
default:
|
||||
throw new Error('msToTimeUnit - invalid time unit');
|
||||
}
|
||||
};
|
||||
253
apps/meteor/client/lib/createComposerAPICore.ts
Normal file
253
apps/meteor/client/lib/createComposerAPICore.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import type { IMessage } from '@rocket.chat/core-typings';
|
||||
import { Emitter } from '@rocket.chat/emitter';
|
||||
import type { RefObject } from 'react';
|
||||
|
||||
import type { ComposerAPI } from './chats/ChatAPI';
|
||||
import { createUploadsAPI } from './chats/uploads';
|
||||
import type { FormattingButton } from './messageBoxFormatting';
|
||||
import { formattingButtons } from './messageBoxFormatting';
|
||||
import { settings } from './settings';
|
||||
import { withDebouncing } from '../../lib/utils/highOrderFunctions';
|
||||
|
||||
export type SetText = (
|
||||
text: string,
|
||||
options?: {
|
||||
selection?:
|
||||
| { readonly start?: number; readonly end?: number }
|
||||
| ((previous: { readonly start: number; readonly end: number }) => { readonly start?: number; readonly end?: number });
|
||||
skipFocus?: boolean;
|
||||
},
|
||||
) => void;
|
||||
|
||||
export const triggerEvent = (input: HTMLElement, evt: string): void => {
|
||||
const event = new Event(evt, { bubbles: true });
|
||||
// TODO: Remove this hack for react to trigger onChange
|
||||
const tracker = (input as any)._valueTracker;
|
||||
if (tracker) {
|
||||
tracker.setValue(new Date().toString());
|
||||
}
|
||||
input.dispatchEvent(event);
|
||||
};
|
||||
|
||||
type ComposerAPICoreParams = {
|
||||
input: HTMLElement;
|
||||
composerRef: RefObject<HTMLElement | null>;
|
||||
room: { rid: string; tmid?: string };
|
||||
initialValue: string;
|
||||
save: () => void;
|
||||
setText: SetText;
|
||||
focus: ComposerAPI['focus'];
|
||||
prepareQuotedMessage?: (message: IMessage) => IMessage;
|
||||
};
|
||||
|
||||
type ComposerAPICore = Pick<
|
||||
ComposerAPI,
|
||||
| 'release'
|
||||
| 'insertText'
|
||||
| 'insertNewLine'
|
||||
| 'clear'
|
||||
| 'focus'
|
||||
| 'blur'
|
||||
| 'quoteMessage'
|
||||
| 'dismissQuotedMessage'
|
||||
| 'dismissAllQuotedMessages'
|
||||
| 'quotedMessages'
|
||||
| 'setEditingMode'
|
||||
| 'editing'
|
||||
| 'setRecordingMode'
|
||||
| 'recording'
|
||||
| 'setRecordingVideo'
|
||||
| 'recordingVideo'
|
||||
| 'setIsMicrophoneDenied'
|
||||
| 'isMicrophoneDenied'
|
||||
| 'formatters'
|
||||
| 'composerRef'
|
||||
| 'uploads'
|
||||
>;
|
||||
|
||||
export const createComposerAPICore = ({
|
||||
input,
|
||||
composerRef,
|
||||
room: { rid, tmid },
|
||||
initialValue,
|
||||
save,
|
||||
setText,
|
||||
focus,
|
||||
prepareQuotedMessage = (message) => message,
|
||||
}: ComposerAPICoreParams): ComposerAPICore => {
|
||||
const emitter = new Emitter<{
|
||||
quotedMessagesUpdate: void;
|
||||
editing: void;
|
||||
recording: void;
|
||||
recordingVideo: void;
|
||||
formatting: void;
|
||||
mircophoneDenied: void;
|
||||
}>();
|
||||
|
||||
let _quotedMessages: IMessage[] = [];
|
||||
|
||||
const persist = withDebouncing({ wait: 300 })(save);
|
||||
|
||||
const notifyQuotedMessagesUpdate = (): void => {
|
||||
emitter.emit('quotedMessagesUpdate');
|
||||
};
|
||||
|
||||
const insertText = (text: string): void => {
|
||||
setText(text, {
|
||||
selection: ({ start, end }) => ({
|
||||
start: start + text.length,
|
||||
end: end + text.length,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const clear = (): void => {
|
||||
setText('');
|
||||
};
|
||||
|
||||
const insertNewLine = (): void => insertText('\n');
|
||||
|
||||
const blur = (): void => input.blur();
|
||||
|
||||
const quoteMessage = async (message: IMessage): Promise<void> => {
|
||||
_quotedMessages = [..._quotedMessages.filter((_message) => _message._id !== message._id), prepareQuotedMessage(message)];
|
||||
notifyQuotedMessagesUpdate();
|
||||
input.focus();
|
||||
};
|
||||
|
||||
const dismissQuotedMessage = async (mid: IMessage['_id']): Promise<void> => {
|
||||
_quotedMessages = _quotedMessages.filter((message) => message._id !== mid);
|
||||
notifyQuotedMessagesUpdate();
|
||||
};
|
||||
|
||||
const dismissAllQuotedMessages = async (): Promise<void> => {
|
||||
_quotedMessages = [];
|
||||
notifyQuotedMessagesUpdate();
|
||||
};
|
||||
|
||||
const quotedMessages = {
|
||||
get: () => _quotedMessages,
|
||||
subscribe: (callback: () => void) => emitter.on('quotedMessagesUpdate', callback),
|
||||
};
|
||||
|
||||
const [editing, setEditing] = (() => {
|
||||
let editing = false;
|
||||
|
||||
return [
|
||||
{
|
||||
get: () => editing,
|
||||
subscribe: (callback: () => void) => emitter.on('editing', callback),
|
||||
},
|
||||
(value: boolean) => {
|
||||
editing = value;
|
||||
emitter.emit('editing');
|
||||
},
|
||||
] as const;
|
||||
})();
|
||||
|
||||
const [recording, setRecordingMode] = (() => {
|
||||
let recording = false;
|
||||
|
||||
return [
|
||||
{
|
||||
get: () => recording,
|
||||
subscribe: (callback: () => void) => emitter.on('recording', callback),
|
||||
},
|
||||
(value: boolean) => {
|
||||
recording = value;
|
||||
emitter.emit('recording');
|
||||
},
|
||||
] as const;
|
||||
})();
|
||||
|
||||
const [recordingVideo, setRecordingVideo] = (() => {
|
||||
let recordingVideo = false;
|
||||
|
||||
return [
|
||||
{
|
||||
get: () => recordingVideo,
|
||||
subscribe: (callback: () => void) => emitter.on('recordingVideo', callback),
|
||||
},
|
||||
(value: boolean) => {
|
||||
recordingVideo = value;
|
||||
emitter.emit('recordingVideo');
|
||||
},
|
||||
] as const;
|
||||
})();
|
||||
|
||||
const [isMicrophoneDenied, setIsMicrophoneDenied] = (() => {
|
||||
let isMicrophoneDenied = false;
|
||||
|
||||
return [
|
||||
{
|
||||
get: () => isMicrophoneDenied,
|
||||
subscribe: (callback: () => void) => emitter.on('mircophoneDenied', callback),
|
||||
},
|
||||
(value: boolean) => {
|
||||
isMicrophoneDenied = value;
|
||||
emitter.emit('mircophoneDenied');
|
||||
},
|
||||
] as const;
|
||||
})();
|
||||
|
||||
const setEditingMode = (editing: boolean): void => {
|
||||
setEditing(editing);
|
||||
};
|
||||
|
||||
const [formatters, stopFormatterSubscription] = (() => {
|
||||
let actions: FormattingButton[] = [];
|
||||
|
||||
const recompute = (): void => {
|
||||
actions = formattingButtons.filter(({ condition }) => !condition || condition());
|
||||
emitter.emit('formatting');
|
||||
};
|
||||
recompute();
|
||||
// Coarse-grained: fires on every setting change, but the only condition()
|
||||
// today is Katex_Enabled and the recompute is a cheap zustand read, so the
|
||||
// extra work per unrelated setting change is negligible.
|
||||
const stop = settings.observe('*', recompute);
|
||||
|
||||
return [
|
||||
{
|
||||
get: () => actions,
|
||||
subscribe: (callback: () => void) => emitter.on('formatting', callback),
|
||||
},
|
||||
stop,
|
||||
] as const;
|
||||
})();
|
||||
|
||||
const release = (): void => {
|
||||
input.removeEventListener('input', persist);
|
||||
stopFormatterSubscription();
|
||||
};
|
||||
|
||||
setText(initialValue, {
|
||||
skipFocus: true,
|
||||
});
|
||||
|
||||
input.addEventListener('input', persist);
|
||||
|
||||
return {
|
||||
release,
|
||||
insertText,
|
||||
insertNewLine,
|
||||
clear,
|
||||
focus,
|
||||
blur,
|
||||
quoteMessage,
|
||||
dismissQuotedMessage,
|
||||
dismissAllQuotedMessages,
|
||||
quotedMessages,
|
||||
setEditingMode,
|
||||
editing,
|
||||
setRecordingMode,
|
||||
recording,
|
||||
setRecordingVideo,
|
||||
recordingVideo,
|
||||
setIsMicrophoneDenied,
|
||||
isMicrophoneDenied,
|
||||
formatters,
|
||||
composerRef,
|
||||
uploads: createUploadsAPI({ rid, tmid }),
|
||||
};
|
||||
};
|
||||
81
apps/meteor/client/lib/createRouteGroup.tsx
Normal file
81
apps/meteor/client/lib/createRouteGroup.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { IRouterPaths, RouteName, RouterPathPattern } from '@rocket.chat/ui-contexts';
|
||||
import type { ElementType, ReactNode } from 'react';
|
||||
|
||||
import { appLayout } from './appLayout';
|
||||
import { router } from '../providers/RouterProvider';
|
||||
import MainLayout from '../views/root/MainLayout';
|
||||
|
||||
type GroupName = 'omnichannel' | 'marketplace' | 'account' | 'admin';
|
||||
|
||||
type GroupPrefix<TGroupName extends GroupName> = IRouterPaths[`${TGroupName}-index`]['pattern'];
|
||||
|
||||
type RouteNamesOf<TGroupName extends GroupName> = (
|
||||
| keyof {
|
||||
[
|
||||
TRouteName in RouteName as IRouterPaths[TRouteName]['pattern'] extends `${GroupPrefix<TGroupName>}/${string}` ? TRouteName : never
|
||||
]: never;
|
||||
}
|
||||
| `${GroupName}-index`
|
||||
) &
|
||||
RouteName;
|
||||
|
||||
type TrimPrefix<T, P extends string> = T extends `${P}${infer U}` ? U : T;
|
||||
|
||||
export const createRouteGroup = <TGroupName extends GroupName>(
|
||||
name: TGroupName,
|
||||
prefix: NoInfer<GroupPrefix<TGroupName>>,
|
||||
RouterComponent: ElementType<{
|
||||
children?: ReactNode;
|
||||
}>,
|
||||
) => {
|
||||
router.defineRoutes([
|
||||
{
|
||||
path: prefix,
|
||||
id: `${name}-index`,
|
||||
element: appLayout.wrap(
|
||||
<MainLayout>
|
||||
<RouterComponent />
|
||||
</MainLayout>,
|
||||
),
|
||||
},
|
||||
]);
|
||||
|
||||
return <TRouteName extends RouteNamesOf<TGroupName>>(
|
||||
path: TrimPrefix<IRouterPaths[TRouteName]['pattern'], GroupPrefix<TGroupName>>,
|
||||
{
|
||||
name,
|
||||
component: RouteComponent,
|
||||
props,
|
||||
ready = true,
|
||||
}: {
|
||||
name: TRouteName;
|
||||
component: ElementType;
|
||||
props?: Record<string, unknown>;
|
||||
ready?: boolean;
|
||||
},
|
||||
): [register: () => void, unregister: () => void] => {
|
||||
let unregister: (() => void) | undefined;
|
||||
|
||||
const register = () => {
|
||||
unregister = router.defineRoutes([
|
||||
{
|
||||
path: `${prefix}${path}` as RouterPathPattern,
|
||||
id: name,
|
||||
element: appLayout.wrap(
|
||||
<MainLayout>
|
||||
<RouterComponent>
|
||||
<RouteComponent {...props} />
|
||||
</RouterComponent>
|
||||
</MainLayout>,
|
||||
),
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
if (ready) {
|
||||
register();
|
||||
}
|
||||
|
||||
return [register, () => unregister?.()];
|
||||
};
|
||||
};
|
||||
63
apps/meteor/client/lib/createSidebarItems.ts
Normal file
63
apps/meteor/client/lib/createSidebarItems.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { Keys as IconName } from '@rocket.chat/icons';
|
||||
import type { LocationPathname } from '@rocket.chat/ui-contexts';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { GO_ROCKET_CHAT_PREFIX } from './links';
|
||||
|
||||
export type Item = {
|
||||
i18nLabel: string;
|
||||
href?: LocationPathname | `${typeof GO_ROCKET_CHAT_PREFIX}${string}`;
|
||||
icon?: IconName;
|
||||
tag?: 'Alpha' | 'Beta';
|
||||
permissionGranted?: () => boolean;
|
||||
pathSection?: string;
|
||||
name?: string;
|
||||
externalUrl?: boolean;
|
||||
badge?: () => ReactNode;
|
||||
};
|
||||
export type SidebarDivider = { divider: boolean; i18nLabel: string };
|
||||
export type SidebarItem = Item | SidebarDivider;
|
||||
export const isSidebarItem = (item: SidebarItem): item is Item => !('divider' in item);
|
||||
|
||||
export const isGoRocketChatLink = (link: string): link is `${typeof GO_ROCKET_CHAT_PREFIX}${string}` =>
|
||||
link.startsWith(GO_ROCKET_CHAT_PREFIX);
|
||||
|
||||
export const createSidebarItems = (
|
||||
initialItems: SidebarItem[] = [],
|
||||
): {
|
||||
registerSidebarItem: (item: SidebarItem) => void;
|
||||
unregisterSidebarItem: (i18nLabel: SidebarItem['i18nLabel']) => void;
|
||||
getSidebarItems: () => SidebarItem[];
|
||||
subscribeToSidebarItems: (callback: () => void) => () => void;
|
||||
} => {
|
||||
let items = initialItems;
|
||||
let updateCb: () => void = () => undefined;
|
||||
|
||||
const getSidebarItems = (): SidebarItem[] => items;
|
||||
|
||||
const subscribeToSidebarItems = (cb: () => void): (() => void) => {
|
||||
updateCb = cb;
|
||||
return (): void => {
|
||||
updateCb = (): void => undefined;
|
||||
};
|
||||
};
|
||||
|
||||
// Snapshots feed useSyncExternalStore, which bails out when the reference is
|
||||
// unchanged, so every mutation has to produce a new array.
|
||||
const registerSidebarItem = (item: SidebarItem): void => {
|
||||
items = [...items, item];
|
||||
updateCb();
|
||||
};
|
||||
|
||||
const unregisterSidebarItem = (i18nLabel: SidebarItem['i18nLabel']): void => {
|
||||
items = items.filter((item) => item.i18nLabel !== i18nLabel);
|
||||
updateCb();
|
||||
};
|
||||
|
||||
return {
|
||||
registerSidebarItem,
|
||||
unregisterSidebarItem,
|
||||
getSidebarItems,
|
||||
subscribeToSidebarItems,
|
||||
};
|
||||
};
|
||||
135
apps/meteor/client/lib/customEmoji.ts
Normal file
135
apps/meteor/client/lib/customEmoji.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import type { IEmoji } from '@rocket.chat/core-typings';
|
||||
import { escapeRegExp } from '@rocket.chat/tools';
|
||||
|
||||
import { emoji, removeFromRecent, replaceEmojiInRecent } from './emoji';
|
||||
import { getURL } from './getURL';
|
||||
|
||||
const isSetNotNull = (fn: () => unknown) => {
|
||||
let value;
|
||||
try {
|
||||
value = fn();
|
||||
} catch (e) {
|
||||
value = null;
|
||||
}
|
||||
return value !== null && value !== undefined;
|
||||
};
|
||||
|
||||
export const updateEmojiCustom = (emojiData: IEmoji) => {
|
||||
const previousExists = isSetNotNull(() => emojiData.previousName);
|
||||
const currentAliases = isSetNotNull(() => emojiData.aliases);
|
||||
|
||||
if (previousExists && isSetNotNull(() => emoji.list[`:${emojiData.previousName}:`].aliases)) {
|
||||
for (const alias of emoji.list[`:${emojiData.previousName}:`].aliases ?? []) {
|
||||
delete emoji.list[`:${alias}:`];
|
||||
const aliasIndex = emoji.packages.emojiCustom.list?.indexOf(`:${alias}:`) ?? -1;
|
||||
if (aliasIndex !== -1) {
|
||||
emoji.packages.emojiCustom.list?.splice(aliasIndex, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (previousExists && emojiData.name !== emojiData.previousName) {
|
||||
const arrayIndex = emoji.packages.emojiCustom.emojisByCategory.rocket.indexOf(emojiData.previousName);
|
||||
if (arrayIndex !== -1) {
|
||||
emoji.packages.emojiCustom.emojisByCategory.rocket.splice(arrayIndex, 1);
|
||||
}
|
||||
const arrayIndexList = emoji.packages.emojiCustom.list?.indexOf(`:${emojiData.previousName}:`) ?? -1;
|
||||
if (arrayIndexList !== -1) {
|
||||
emoji.packages.emojiCustom.list?.splice(arrayIndexList, 1);
|
||||
}
|
||||
delete emoji.list[`:${emojiData.previousName}:`];
|
||||
}
|
||||
|
||||
const categoryIndex = emoji.packages.emojiCustom.emojisByCategory.rocket.indexOf(`${emojiData.name}`);
|
||||
if (categoryIndex === -1) {
|
||||
emoji.packages.emojiCustom.emojisByCategory.rocket.push(`${emojiData.name}`);
|
||||
emoji.packages.emojiCustom.list?.push(`:${emojiData.name}:`);
|
||||
}
|
||||
// Don't inherit fields from a native emoji being overridden (e.g. its unicode), or the pick would output the native emoji
|
||||
// TODO: Fix the IEmoji type and standardize the emoji packs types
|
||||
emoji.list[`:${emojiData.name}:`] = {
|
||||
...emojiData,
|
||||
emojiPackage: 'emojiCustom',
|
||||
} as unknown as (typeof emoji.list)[keyof typeof emoji.list];
|
||||
if (currentAliases) {
|
||||
for (const alias of emojiData.aliases) {
|
||||
emoji.packages.emojiCustom.list?.push(`:${alias}:`);
|
||||
emoji.list[`:${alias}:`] = {
|
||||
emojiPackage: 'emojiCustom',
|
||||
aliasOf: emojiData.name,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (previousExists) {
|
||||
replaceEmojiInRecent({ oldEmoji: emojiData.previousName, newEmoji: emojiData.name });
|
||||
}
|
||||
|
||||
emoji.dispatchUpdate();
|
||||
};
|
||||
|
||||
export const deleteEmojiCustom = (emojiData: IEmoji) => {
|
||||
delete emoji.list[`:${emojiData.name}:`];
|
||||
const arrayIndex = emoji.packages.emojiCustom.emojisByCategory.rocket.indexOf(emojiData.name);
|
||||
if (arrayIndex !== -1) {
|
||||
emoji.packages.emojiCustom.emojisByCategory.rocket.splice(arrayIndex, 1);
|
||||
}
|
||||
const arrayIndexList = emoji.packages.emojiCustom.list?.indexOf(`:${emojiData.name}:`) ?? -1;
|
||||
if (arrayIndexList !== -1) {
|
||||
emoji.packages.emojiCustom.list?.splice(arrayIndexList, 1);
|
||||
}
|
||||
if (emojiData.aliases) {
|
||||
for (const alias of emojiData.aliases) {
|
||||
delete emoji.list[`:${alias}:`];
|
||||
const aliasIndex = emoji.packages.emojiCustom.list?.indexOf(`:${alias}:`) ?? -1;
|
||||
if (aliasIndex !== -1) {
|
||||
emoji.packages.emojiCustom.list?.splice(aliasIndex, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removeFromRecent(emojiData.name, emoji.packages.base.emojisByCategory.recent);
|
||||
emoji.dispatchUpdate();
|
||||
};
|
||||
|
||||
const getEmojiUrlFromName = (name: string, extension: string, etag?: string) => {
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
|
||||
return getURL(`/emoji-custom/${encodeURIComponent(name)}.${extension}${etag ? `?etag=${etag}` : ''}`);
|
||||
};
|
||||
|
||||
export const customRender = (html: string) => {
|
||||
const emojisMatchGroup = emoji.packages.emojiCustom.list?.map(escapeRegExp).join('|');
|
||||
if (emojisMatchGroup !== emoji.packages.emojiCustom._regexpSignature) {
|
||||
emoji.packages.emojiCustom._regexpSignature = emojisMatchGroup;
|
||||
emoji.packages.emojiCustom._regexp = new RegExp(
|
||||
`<object[^>]*>.*?<\/object>|<span[^>]*>.*?<\/span>|<(?:object|embed|svg|img|div|span|p|a)[^>]*>|(${emojisMatchGroup})`,
|
||||
'gi',
|
||||
);
|
||||
emoji.dispatchUpdate();
|
||||
}
|
||||
|
||||
html = html.replace(emoji.packages.emojiCustom._regexp!, (shortname) => {
|
||||
if (typeof shortname === 'undefined' || shortname === '' || (emoji.packages.emojiCustom.list?.indexOf(shortname) ?? -1) === -1) {
|
||||
return shortname;
|
||||
}
|
||||
|
||||
let emojiAlias = shortname.replace(/:/g, '');
|
||||
|
||||
let dataCheck = emoji.list[shortname];
|
||||
if (dataCheck.aliasOf) {
|
||||
emojiAlias = dataCheck.aliasOf;
|
||||
dataCheck = emoji.list[`:${emojiAlias}:`];
|
||||
}
|
||||
|
||||
return `<span class="emoji emoji--custom" style="background-image:url(${getEmojiUrlFromName(
|
||||
emojiAlias,
|
||||
dataCheck.extension!,
|
||||
dataCheck.etag,
|
||||
)});" data-emoji="${emojiAlias}" title="${shortname}">${shortname}</span>`;
|
||||
});
|
||||
|
||||
return html;
|
||||
};
|
||||
141
apps/meteor/client/lib/customOAuth/CustomOAuth.ts
Normal file
141
apps/meteor/client/lib/customOAuth/CustomOAuth.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import type { OAuthConfiguration, OauthConfig } from '@rocket.chat/core-typings';
|
||||
import { Random } from '@rocket.chat/random';
|
||||
import { capitalize, isAbsoluteURL } from '@rocket.chat/tools';
|
||||
import { Accounts } from 'meteor/accounts-base';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { OAuth } from 'meteor/oauth';
|
||||
|
||||
import { CustomOAuthError } from './CustomOAuthError';
|
||||
import type { IOAuthProvider } from '../../definitions/IOAuthProvider';
|
||||
import { createOAuthTotpLoginMethod } from '../../meteor/login/oauth';
|
||||
import { overrideLoginMethod, type LoginCallback } from '../2fa/overrideLoginMethod';
|
||||
import { loginServices } from '../loginServices';
|
||||
|
||||
const configuredOAuthServices = new Map<string, CustomOAuth>();
|
||||
|
||||
export class CustomOAuth<TServiceName extends string = string> implements IOAuthProvider {
|
||||
public serverURL: string;
|
||||
|
||||
public authorizePath: string;
|
||||
|
||||
public scope: string;
|
||||
|
||||
public responseType: string;
|
||||
|
||||
constructor(
|
||||
public readonly name: TServiceName,
|
||||
options: Readonly<OauthConfig>,
|
||||
) {
|
||||
this.configure(options);
|
||||
|
||||
Accounts.oauth.registerService(this.name);
|
||||
|
||||
this.configureLogin();
|
||||
}
|
||||
|
||||
configure(options: Readonly<OauthConfig>) {
|
||||
if (typeof options !== 'object' || !options) {
|
||||
throw new CustomOAuthError('options is required and must be object');
|
||||
}
|
||||
|
||||
if (typeof options.serverURL !== 'string') {
|
||||
throw new CustomOAuthError('options.serverURL is required and must be string');
|
||||
}
|
||||
|
||||
this.serverURL = options.serverURL;
|
||||
this.authorizePath = options.authorizePath ?? '/oauth/authorize';
|
||||
this.scope = options.scope ?? 'openid';
|
||||
this.responseType = options.responseType || 'code';
|
||||
|
||||
if (!isAbsoluteURL(this.authorizePath)) {
|
||||
this.authorizePath = this.serverURL + this.authorizePath;
|
||||
}
|
||||
}
|
||||
|
||||
configureLogin() {
|
||||
const loginWithService = `loginWith${capitalize(this.name)}` as const;
|
||||
|
||||
const loginWithOAuthTokenAndTOTP = createOAuthTotpLoginMethod(this);
|
||||
|
||||
const loginWithOAuthToken = async (options?: Meteor.LoginWithExternalServiceOptions, callback?: LoginCallback) => {
|
||||
const credentialRequestCompleteCallback = Accounts.oauth.credentialRequestCompleteHandler(callback);
|
||||
await this.requestCredential(options, credentialRequestCompleteCallback);
|
||||
};
|
||||
|
||||
(Meteor as any)[loginWithService] = (options: Meteor.LoginWithExternalServiceOptions, callback: LoginCallback) => {
|
||||
overrideLoginMethod(loginWithOAuthToken, [options], callback, loginWithOAuthTokenAndTOTP);
|
||||
};
|
||||
}
|
||||
|
||||
async requestCredential(
|
||||
options: Meteor.LoginWithExternalServiceOptions = {},
|
||||
credentialRequestCompleteCallback: (credentialTokenOrError?: string | Error) => void,
|
||||
) {
|
||||
const config = await loginServices.loadLoginService<OAuthConfiguration>(this.name);
|
||||
if (!config) {
|
||||
if (credentialRequestCompleteCallback) {
|
||||
credentialRequestCompleteCallback(new Accounts.ConfigError());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const credentialToken = Random.secret();
|
||||
const loginStyle = OAuth._loginStyle(this.name, config);
|
||||
|
||||
const separator = this.authorizePath.indexOf('?') !== -1 ? '&' : '?';
|
||||
|
||||
const loginUrl =
|
||||
`${this.authorizePath}${separator}client_id=${config.clientId}&redirect_uri=${encodeURIComponent(
|
||||
OAuth._redirectUri(this.name, config),
|
||||
)}&response_type=${encodeURIComponent(this.responseType)}` +
|
||||
`&state=${encodeURIComponent(OAuth._stateParam(loginStyle, credentialToken, options.redirectUrl))}&scope=${encodeURIComponent(
|
||||
this.scope,
|
||||
)}`;
|
||||
|
||||
OAuth.launchLogin({
|
||||
loginService: this.name,
|
||||
loginStyle,
|
||||
loginUrl,
|
||||
credentialRequestCompleteCallback,
|
||||
credentialToken,
|
||||
popupOptions: {
|
||||
width: 900,
|
||||
height: 450,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
static configureOAuthService<TServiceName extends string = string>(
|
||||
serviceName: TServiceName,
|
||||
options: Readonly<OauthConfig>,
|
||||
): CustomOAuth<TServiceName> {
|
||||
const existingInstance = configuredOAuthServices.get(serviceName);
|
||||
if (existingInstance) {
|
||||
existingInstance.configure(options);
|
||||
return existingInstance as CustomOAuth<TServiceName>;
|
||||
}
|
||||
|
||||
// If we don't have a reference to the instance for this service and it was already registered on meteor,
|
||||
// then there's nothing we can do to update it
|
||||
if (Accounts.oauth.serviceNames().includes(serviceName)) {
|
||||
throw new CustomOAuthError('service already registered, skipping new configuration', { service: serviceName });
|
||||
}
|
||||
|
||||
const instance = new CustomOAuth(serviceName, options);
|
||||
configuredOAuthServices.set(serviceName, instance);
|
||||
return instance;
|
||||
}
|
||||
|
||||
static configureCustomOAuthService<TServiceName extends string = string>(
|
||||
serviceName: TServiceName,
|
||||
options: Readonly<OauthConfig>,
|
||||
): CustomOAuth<TServiceName> | undefined {
|
||||
// Custom OAuth services are configured based on the login service list, so if this ends up being called multiple times, simply ignore it
|
||||
// Non-Custom OAuth services are configured based on code, so if configureOAuthService is called multiple times for them, it's a bug and it should throw.
|
||||
try {
|
||||
return this.configureOAuthService(serviceName, options);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
apps/meteor/client/lib/customOAuth/CustomOAuthError.ts
Normal file
11
apps/meteor/client/lib/customOAuth/CustomOAuthError.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { RocketChatError } from '../errors/RocketChatError';
|
||||
|
||||
type CustomOAuthErrorDetails = {
|
||||
service?: string;
|
||||
};
|
||||
|
||||
export class CustomOAuthError extends RocketChatError<'custom-oauth-error', CustomOAuthErrorDetails> {
|
||||
constructor(reason?: string, details?: CustomOAuthErrorDetails) {
|
||||
super('custom-oauth-error', details?.service ? `${details.service}: ${reason}` : reason, details);
|
||||
}
|
||||
}
|
||||
55
apps/meteor/client/lib/download.spec.ts
Normal file
55
apps/meteor/client/lib/download.spec.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { download, downloadAs, downloadCsvAs, downloadJsonAs } from './download';
|
||||
|
||||
describe('download', () => {
|
||||
it('should work', () => {
|
||||
const listener = jest.fn();
|
||||
document.addEventListener('click', listener, false);
|
||||
|
||||
download('about:blank', 'blank');
|
||||
|
||||
document.removeEventListener('click', listener, false);
|
||||
expect(listener).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('downloadAs', () => {
|
||||
it('should work', () => {
|
||||
const listener = jest.fn();
|
||||
document.addEventListener('click', listener, false);
|
||||
|
||||
downloadAs({ data: [] }, 'blank');
|
||||
|
||||
document.removeEventListener('click', listener, false);
|
||||
expect(listener).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('downloadJsonAs', () => {
|
||||
it('should work', () => {
|
||||
const listener = jest.fn();
|
||||
document.addEventListener('click', listener, false);
|
||||
|
||||
downloadJsonAs({}, 'blank');
|
||||
|
||||
document.removeEventListener('click', listener, false);
|
||||
expect(listener).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('downloadCsvAs', () => {
|
||||
it('should work', () => {
|
||||
const listener = jest.fn();
|
||||
document.addEventListener('click', listener, false);
|
||||
|
||||
downloadCsvAs(
|
||||
[
|
||||
[1, 2, 3],
|
||||
[4, 5, 6],
|
||||
],
|
||||
'blank',
|
||||
);
|
||||
|
||||
document.removeEventListener('click', listener, false);
|
||||
expect(listener).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
52
apps/meteor/client/lib/download.ts
Normal file
52
apps/meteor/client/lib/download.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
export const download = (href: string, filename: string): void => {
|
||||
const anchorElement = document.createElement('a');
|
||||
anchorElement.download = filename;
|
||||
anchorElement.href = href;
|
||||
anchorElement.target = '_blank';
|
||||
document.body.appendChild(anchorElement);
|
||||
anchorElement.click();
|
||||
document.body.removeChild(anchorElement);
|
||||
};
|
||||
|
||||
const hasMsSaveOrOpenBlob = (navigator: Navigator): navigator is Navigator & { msSaveOrOpenBlob: (blob: Blob) => void } =>
|
||||
'msSaveOrOpenBlob' in navigator;
|
||||
|
||||
export const downloadAs = ({ data, ...options }: { data: BlobPart[] } & BlobPropertyBag, filename: string): void => {
|
||||
const blob = new Blob(data, options);
|
||||
|
||||
if (hasMsSaveOrOpenBlob(navigator)) {
|
||||
navigator.msSaveOrOpenBlob(blob);
|
||||
return;
|
||||
}
|
||||
|
||||
const URL = window.webkitURL ?? window.URL;
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
|
||||
download(blobUrl, filename);
|
||||
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
};
|
||||
|
||||
export const downloadJsonAs = (jsonObject: unknown, basename: string): void => {
|
||||
downloadAs(
|
||||
{
|
||||
data: [decodeURIComponent(encodeURI(JSON.stringify(jsonObject, null, 2)))],
|
||||
type: 'application/json;charset=utf-8',
|
||||
},
|
||||
`${basename}.json`,
|
||||
);
|
||||
};
|
||||
|
||||
export const downloadCsvAs = (csvData: readonly (readonly unknown[])[], basename: string): void => {
|
||||
const escapeCell = (cell: unknown): string => `"${String(cell).replace(/"/g, '""')}"`;
|
||||
const content = csvData.reduce((content, row) => `${content + row.map(escapeCell).join(';')}\n`, '');
|
||||
|
||||
downloadAs(
|
||||
{
|
||||
data: [decodeURIComponent(encodeURI(content))],
|
||||
type: 'text/csv;charset=utf-8',
|
||||
endings: 'native',
|
||||
},
|
||||
`${basename}.csv`,
|
||||
);
|
||||
};
|
||||
1
apps/meteor/client/lib/e2ee/E2EEState.ts
Normal file
1
apps/meteor/client/lib/e2ee/E2EEState.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type E2EEState = 'NOT_STARTED' | 'DISABLED' | 'LOADING_KEYS' | 'READY' | 'SAVE_PASSWORD' | 'ENTER_PASSWORD' | 'ERROR';
|
||||
2
apps/meteor/client/lib/e2ee/E2ERoomState.ts
Normal file
2
apps/meteor/client/lib/e2ee/E2ERoomState.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export type E2ERoomState =
|
||||
'NOT_STARTED' | 'DISABLED' | 'ESTABLISHING' | 'CREATING_KEYS' | 'WAITING_KEYS' | 'KEYS_RECEIVED' | 'READY' | 'ERROR';
|
||||
37
apps/meteor/client/lib/e2ee/binary.spec.ts
Normal file
37
apps/meteor/client/lib/e2ee/binary.spec.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Binary } from './binary';
|
||||
|
||||
describe('Binary', () => {
|
||||
describe('toString', () => {
|
||||
it('should convert ArrayBuffer to string', () => {
|
||||
const array = new Uint8Array([72, 101, 108, 108, 111]); // "Hello"
|
||||
const result = Binary.encode(array.buffer);
|
||||
expect(result).toBe('Hello');
|
||||
});
|
||||
|
||||
it('should handle empty ArrayBuffer', () => {
|
||||
const buffer = new ArrayBuffer(0);
|
||||
const result = Binary.encode(buffer);
|
||||
expect(result).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toArrayBuffer', () => {
|
||||
it('should convert string to ArrayBuffer', () => {
|
||||
const str = 'Hello';
|
||||
const buffer = Binary.decode(str);
|
||||
const uint8 = new Uint8Array(buffer);
|
||||
expect(Array.from(uint8)).toEqual([72, 101, 108, 108, 111]);
|
||||
});
|
||||
|
||||
it('should handle empty string', () => {
|
||||
const str = '';
|
||||
const buffer = Binary.decode(str);
|
||||
expect(buffer.byteLength).toBe(0);
|
||||
});
|
||||
|
||||
it('should throw RangeError for illegal char code', () => {
|
||||
const str = 'Hello\u0100'; // Character with char code 256
|
||||
expect(() => Binary.decode(str)).toThrowErrorMatchingInlineSnapshot(`"illegal char code: 256"`);
|
||||
});
|
||||
});
|
||||
});
|
||||
32
apps/meteor/client/lib/e2ee/binary.ts
Normal file
32
apps/meteor/client/lib/e2ee/binary.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { ICodec } from './codec';
|
||||
|
||||
export const Binary: ICodec<string, ArrayBuffer> = {
|
||||
encode(buffer: ArrayBuffer): string {
|
||||
const uint8 = new Uint8Array(buffer);
|
||||
const CHUNK_SIZE = 8192; // Process in chunks for performance
|
||||
let result = '';
|
||||
for (let i = 0; i < uint8.length; i += CHUNK_SIZE) {
|
||||
const chunk = uint8.subarray(i, i + CHUNK_SIZE);
|
||||
result += String.fromCharCode(...chunk);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
decode(str: string): ArrayBuffer {
|
||||
// Create a Uint8Array of the same length as the string.
|
||||
// This will be a view on the new ArrayBuffer.
|
||||
const buffer = new ArrayBuffer(str.length);
|
||||
const uint8 = new Uint8Array(buffer);
|
||||
|
||||
// Iterate through the string, getting the character code for each
|
||||
// character and setting it as the value for the corresponding byte.
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const charCode = str.charCodeAt(i);
|
||||
if (charCode > 0xff) {
|
||||
throw new RangeError(`illegal char code: ${charCode}`);
|
||||
}
|
||||
uint8[i] = charCode;
|
||||
}
|
||||
|
||||
return buffer;
|
||||
},
|
||||
};
|
||||
4
apps/meteor/client/lib/e2ee/codec.ts
Normal file
4
apps/meteor/client/lib/e2ee/codec.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface ICodec<TIn, TOut, TEnc = TIn> {
|
||||
decode: (data: TIn) => TOut;
|
||||
encode: (data: TOut) => TEnc;
|
||||
}
|
||||
144
apps/meteor/client/lib/e2ee/content.spec.ts
Normal file
144
apps/meteor/client/lib/e2ee/content.spec.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { decodeEncryptedContent } from './content';
|
||||
import { importKey, decrypt, type Key } from './crypto/aes';
|
||||
|
||||
describe('content', () => {
|
||||
const msgv1web = Object.freeze({
|
||||
_id: 'JfAxN6Ncsw2XS9eiY',
|
||||
rid: '68dad82a10815056615446aa',
|
||||
e2eMentions: {
|
||||
e2eUserMentions: [],
|
||||
e2eChannelMentions: [],
|
||||
},
|
||||
content: Object.freeze({
|
||||
algorithm: 'rc.v1.aes-sha2',
|
||||
ciphertext: '32c9e7917b78LHjHfqLMeDn+2UK1PhD/soFe8CVwvFdLkslcfxNHby4=',
|
||||
}),
|
||||
t: 'e2e',
|
||||
ts: {
|
||||
$date: '2025-09-29T19:07:07.908Z',
|
||||
},
|
||||
u: {
|
||||
_id: 'bm4cAAcN92jgXe2jN',
|
||||
username: 'alice',
|
||||
name: 'alice',
|
||||
},
|
||||
msg: '',
|
||||
_updatedAt: {
|
||||
$date: '2025-09-29T19:07:07.929Z',
|
||||
},
|
||||
urls: [],
|
||||
mentions: [],
|
||||
channels: [],
|
||||
});
|
||||
|
||||
const msgv1mob = Object.freeze({
|
||||
_id: 'AZF8Myj605B3f7ZPL',
|
||||
rid: '68dad82a10815056615446aa',
|
||||
msg: '32c9e7917b783JpM8aOVludqIRzx+DOqjEU9Mj3NUWb+/GLRl7sdkvTtCMChH1LBjMjJJvVJ6Rlw4dI8BYFftZWiCOiR7TPwriCoSPiZ7dY5C4H2q8MVSdR95ZiyG7eWQ5j5/rxzAYsSWDA9LkumW8JBb+WQ1hD9JMfQd4IXtlFMnaDgEhZhe/s=',
|
||||
t: 'e2e',
|
||||
e2e: 'pending',
|
||||
e2eMentions: {
|
||||
e2eUserMentions: [],
|
||||
e2eChannelMentions: [],
|
||||
},
|
||||
content: Object.freeze({
|
||||
algorithm: 'rc.v1.aes-sha2',
|
||||
ciphertext:
|
||||
'32c9e7917b783JpM8aOVludqIRzx+DOqjEU9Mj3NUWb+/GLRl7sdkvTtCMChH1LBjMjJJvVJ6Rlw4dI8BYFftZWiCOiR7TPwriCoSPiZ7dY5C4H2q8MVSdR95ZiyG7eWQ5j5/rxzAYsSWDA9LkumW8JBb+WQ1hD9JMfQd4IXtlFMnaDgEhZhe/s=',
|
||||
}),
|
||||
ts: {
|
||||
$date: '2025-09-29T19:28:35.261Z',
|
||||
},
|
||||
u: {
|
||||
_id: 'RQTYT5RJoDKZFwDhk',
|
||||
username: 'bob',
|
||||
name: 'bob',
|
||||
},
|
||||
_updatedAt: {
|
||||
$date: '2025-09-29T19:28:35.274Z',
|
||||
},
|
||||
urls: [],
|
||||
mentions: [],
|
||||
channels: [],
|
||||
});
|
||||
|
||||
describe('v1 messages', () => {
|
||||
let key: Key<{ name: 'AES-CBC'; length: 128 }>;
|
||||
|
||||
beforeAll(async () => {
|
||||
key = await importKey({
|
||||
alg: 'A128CBC',
|
||||
ext: true,
|
||||
k: 'qb8In0Rpa9nwSusvxxDcbQ',
|
||||
key_ops: ['encrypt', 'decrypt'],
|
||||
kty: 'oct',
|
||||
});
|
||||
});
|
||||
|
||||
test('parse v1 web message', async () => {
|
||||
const parsed = decodeEncryptedContent(msgv1web.content);
|
||||
const decrypted = await decrypt(key, parsed);
|
||||
expect(decrypted).toMatchInlineSnapshot(`"{"msg":"hello"}"`);
|
||||
});
|
||||
|
||||
test('parse v1 mobile message', async () => {
|
||||
const parsed = decodeEncryptedContent(msgv1mob.content);
|
||||
const decrypted = await decrypt(key, parsed);
|
||||
expect(decrypted).toMatchInlineSnapshot(
|
||||
`"{"_id":"AZF8Myj605B3f7ZPL","text":"world","userId":"RQTYT5RJoDKZFwDhk","ts":{"$date":1759174115076}}"`,
|
||||
);
|
||||
});
|
||||
|
||||
test('parse v1 mobile message from msg field', async () => {
|
||||
const parsed = decodeEncryptedContent(msgv1mob.msg);
|
||||
const decrypted = await decrypt(key, parsed);
|
||||
expect(decrypted).toMatchInlineSnapshot(
|
||||
`"{"_id":"AZF8Myj605B3f7ZPL","text":"world","userId":"RQTYT5RJoDKZFwDhk","ts":{"$date":1759174115076}}"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const msgv2web = Object.freeze({
|
||||
_id: 'h6sXWTiKcWfcgkhgo',
|
||||
rid: '68c9da1b0427bc33b429207e',
|
||||
e2eMentions: {
|
||||
e2eUserMentions: [],
|
||||
e2eChannelMentions: [],
|
||||
},
|
||||
content: Object.freeze({
|
||||
algorithm: 'rc.v2.aes-sha2',
|
||||
kid: 'f46d2864-0384-4a87-8815-51fba2cad216',
|
||||
iv: 'wXbYQ8q9sYRCHtNp',
|
||||
ciphertext: 'cIDO9mXzCCrrl/wORP0Jf6oWeusqzSCXVGGvY7CHrA==',
|
||||
}),
|
||||
t: 'e2e',
|
||||
ts: {
|
||||
$date: '2025-09-30T18:05:30.876Z',
|
||||
},
|
||||
u: {
|
||||
_id: 'Ctk47kkuzJihnmvZE',
|
||||
username: 'alice',
|
||||
name: 'Alice',
|
||||
},
|
||||
msg: '',
|
||||
_updatedAt: {
|
||||
$date: '2025-09-30T18:05:30.887Z',
|
||||
},
|
||||
urls: [],
|
||||
mentions: [],
|
||||
channels: [],
|
||||
});
|
||||
|
||||
test('parse v2 web message', async () => {
|
||||
const parsed = decodeEncryptedContent(msgv2web.content);
|
||||
const key = await importKey({
|
||||
alg: 'A256GCM',
|
||||
ext: true,
|
||||
k: '9o1xoHt4OamRJvnaLna-5akUb5L98S_iWYGGaXPZ1Yg',
|
||||
key_ops: ['encrypt', 'decrypt'],
|
||||
kty: 'oct',
|
||||
});
|
||||
const decrypted = await decrypt(key, parsed);
|
||||
expect(decrypted).toMatchInlineSnapshot(`"{"msg":"hello"}"`);
|
||||
});
|
||||
});
|
||||
92
apps/meteor/client/lib/e2ee/content.ts
Normal file
92
apps/meteor/client/lib/e2ee/content.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { Base64 } from '@rocket.chat/base64';
|
||||
import type { EncryptedContent } from '@rocket.chat/core-typings';
|
||||
|
||||
type DecodedContent = {
|
||||
kid: string;
|
||||
iv: Uint8Array<ArrayBuffer>;
|
||||
ciphertext: Uint8Array<ArrayBuffer>;
|
||||
};
|
||||
|
||||
interface ISlice {
|
||||
slice(start: number, end?: number): this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a slice of data into two parts at the specified index.
|
||||
* @param data The data to split.
|
||||
* @param index The index at which to split the data.
|
||||
* @returns A tuple containing the two parts of the split data.
|
||||
*/
|
||||
const split = <T extends ISlice>(data: T, index: number): [T, T] => [data.slice(0, index), data.slice(index)];
|
||||
|
||||
/**
|
||||
* Decodes an encrypted content string in the format "kid + base64(iv + ciphertext)".
|
||||
* @param payload The encrypted content string.
|
||||
* @returns An object containing the key ID (kid), initialization vector (iv), and ciphertext.
|
||||
*/
|
||||
const decodeV1EncryptedContent = (
|
||||
payload: Omit<Extract<EncryptedContent, { algorithm: 'rc.v1.aes-sha2' }>, 'algorithm'>,
|
||||
): DecodedContent => {
|
||||
if (payload.ciphertext.length < 12) {
|
||||
throw new Error('Invalid v1 ciphertext: too short for kid extraction');
|
||||
}
|
||||
|
||||
const [kid, base64] = split(payload.ciphertext, 12);
|
||||
const decoded = Base64.decode(base64);
|
||||
|
||||
if (decoded.length < 16) {
|
||||
throw new Error('Invalid v1 ciphertext: too short for iv extraction');
|
||||
}
|
||||
|
||||
const [iv, ciphertext] = split(decoded, 16);
|
||||
|
||||
return {
|
||||
kid,
|
||||
iv,
|
||||
ciphertext,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Decodes an encrypted content object with separate fields for kid, iv, and ciphertext.
|
||||
* @param payload The encrypted content object.
|
||||
* @returns An object containing the key ID (kid), initialization vector (iv), and ciphertext.
|
||||
*/
|
||||
const decodeV2EncryptedContent = (payload: Extract<EncryptedContent, { algorithm: 'rc.v2.aes-sha2' }>): DecodedContent => {
|
||||
if (!payload.kid || !payload.iv || !payload.ciphertext) {
|
||||
throw new Error('Invalid v2 payload: kid, iv, and ciphertext must be non-empty');
|
||||
}
|
||||
|
||||
const iv = Base64.decode(payload.iv);
|
||||
const ciphertext = Base64.decode(payload.ciphertext);
|
||||
return { kid: payload.kid, iv, ciphertext };
|
||||
};
|
||||
|
||||
const normalizePayload = (payload: string | EncryptedContent): EncryptedContent => {
|
||||
if (typeof payload === 'string') {
|
||||
return { algorithm: 'rc.v1.aes-sha2', ciphertext: payload } as const;
|
||||
}
|
||||
|
||||
return payload;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses encrypted content from either a string or an object and decodes it into its components.
|
||||
* @param payload The encrypted content, either as a string or an object.
|
||||
* @returns An object containing the key ID (kid), initialization vector (iv), and ciphertext.
|
||||
* @throws Will throw an error if the encryption algorithm is unsupported.
|
||||
*/
|
||||
export const decodeEncryptedContent = (payload: string | EncryptedContent): DecodedContent => {
|
||||
payload = normalizePayload(payload);
|
||||
|
||||
const { algorithm } = payload;
|
||||
|
||||
switch (algorithm) {
|
||||
case 'rc.v1.aes-sha2':
|
||||
return decodeV1EncryptedContent(payload);
|
||||
case 'rc.v2.aes-sha2':
|
||||
return decodeV2EncryptedContent(payload);
|
||||
default:
|
||||
throw new Error(`Unsupported encryption algorithm: ${algorithm}`);
|
||||
}
|
||||
};
|
||||
97
apps/meteor/client/lib/e2ee/crypto/aes.spec.ts
Normal file
97
apps/meteor/client/lib/e2ee/crypto/aes.spec.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { generate, decrypt, encrypt, exportJwk, importKey, type Key } from './aes';
|
||||
|
||||
describe('aes', () => {
|
||||
describe('256-gcm', () => {
|
||||
let key: Key<{ name: 'AES-GCM'; length: 256 }>;
|
||||
|
||||
beforeAll(async () => {
|
||||
key = await generate();
|
||||
});
|
||||
|
||||
it('generate a key with correct properties', async () => {
|
||||
expect<256>(key.algorithm.length).toBe(256);
|
||||
expect<'AES-GCM'>(key.algorithm.name).toBe('AES-GCM');
|
||||
expect<true>(key.extractable).toBe(true);
|
||||
expect<'secret'>(key.type).toBe('secret');
|
||||
expect<2>(key.usages.length).toBe(2);
|
||||
expect<['encrypt', 'decrypt'] | ['decrypt', 'encrypt']>(key.usages).toEqual(['encrypt', 'decrypt']);
|
||||
});
|
||||
|
||||
it('should encrypt and decrypt data correctly', async () => {
|
||||
const plaintext = new TextEncoder().encode('Hello, world!');
|
||||
const ciphertext = await encrypt(key, plaintext);
|
||||
const decrypted = await decrypt(key, ciphertext);
|
||||
expect(decrypted).toBe('Hello, world!');
|
||||
});
|
||||
|
||||
it('should export and re-import the key correctly', async () => {
|
||||
const jwk = await exportJwk(key);
|
||||
const importedKey = await importKey(jwk);
|
||||
expect<256>(importedKey.algorithm.length).toBe(256);
|
||||
expect<'AES-GCM'>(importedKey.algorithm.name).toBe('AES-GCM');
|
||||
expect<true>(importedKey.extractable).toBe(true);
|
||||
expect<'secret'>(importedKey.type).toBe('secret');
|
||||
expect<2>(importedKey.usages.length).toBe(2);
|
||||
expect<['encrypt', 'decrypt'] | ['decrypt', 'encrypt']>(importedKey.usages).toEqual(['encrypt', 'decrypt']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('128-cbc', () => {
|
||||
let key: Key<{ name: 'AES-CBC'; length: 128 }>;
|
||||
|
||||
beforeAll(async () => {
|
||||
key = await importKey({ alg: 'A128CBC', ext: true, k: 'qb8In0Rpa9nwSusvxxDcbQ', key_ops: ['encrypt', 'decrypt'], kty: 'oct' });
|
||||
});
|
||||
|
||||
it('import a key with correct properties', async () => {
|
||||
expect<128>(key.algorithm.length).toBe(128);
|
||||
expect<'AES-CBC'>(key.algorithm.name).toBe('AES-CBC');
|
||||
expect<true>(key.extractable).toBe(true);
|
||||
expect<'secret'>(key.type).toBe('secret');
|
||||
expect<2>(key.usages.length).toBe(2);
|
||||
expect<['encrypt', 'decrypt'] | ['decrypt', 'encrypt']>(key.usages).toEqual(['encrypt', 'decrypt']);
|
||||
});
|
||||
|
||||
it('should encrypt and decrypt data correctly', async () => {
|
||||
const plaintext = new TextEncoder().encode('Hello, AES-CBC!');
|
||||
const ciphertext = await encrypt(key, plaintext);
|
||||
const decrypted = await decrypt(key, ciphertext);
|
||||
expect(decrypted).toBe('Hello, AES-CBC!');
|
||||
});
|
||||
|
||||
it('should export and re-import the key correctly', async () => {
|
||||
const jwk = await exportJwk(key);
|
||||
const importedKey = await importKey(jwk);
|
||||
expect<128>(importedKey.algorithm.length).toBe(128);
|
||||
expect<'AES-CBC'>(importedKey.algorithm.name).toBe('AES-CBC');
|
||||
expect<true>(importedKey.extractable).toBe(true);
|
||||
expect<'secret'>(importedKey.type).toBe('secret');
|
||||
expect<2>(importedKey.usages.length).toBe(2);
|
||||
expect<['encrypt', 'decrypt'] | ['decrypt', 'encrypt']>(importedKey.usages).toEqual(['encrypt', 'decrypt']);
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw on unsupported JWK alg', async () => {
|
||||
await expect(
|
||||
importKey({
|
||||
// @ts-expect-error testing invalid alg
|
||||
alg: 'A128GCM',
|
||||
ext: true,
|
||||
k: 'qb8In0Rpa9nwSusvxxDcbQ',
|
||||
key_ops: ['encrypt', 'decrypt'],
|
||||
kty: 'oct',
|
||||
}),
|
||||
).rejects.toThrow('Unrecognized algorithm name');
|
||||
|
||||
await expect(
|
||||
importKey({
|
||||
// @ts-expect-error testing invalid alg
|
||||
alg: 'A256CBC',
|
||||
ext: true,
|
||||
k: '9o1xoHt4OamRJvnaLna-5akUb5L98S_iWYGGaXPZ1Yg',
|
||||
key_ops: ['encrypt', 'decrypt'],
|
||||
kty: 'oct',
|
||||
}),
|
||||
).rejects.toThrow('Unrecognized algorithm name');
|
||||
});
|
||||
});
|
||||
62
apps/meteor/client/lib/e2ee/crypto/aes.ts
Normal file
62
apps/meteor/client/lib/e2ee/crypto/aes.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { importJwk, exportKey, getRandomValues, generateKey, type IKey, type Exported, encryptBuffer, decryptBuffer } from './shared';
|
||||
|
||||
type AlgorithmMap = {
|
||||
A256GCM: { name: 'AES-GCM'; length: 256 };
|
||||
A128CBC: { name: 'AES-CBC'; length: 128 };
|
||||
};
|
||||
|
||||
const ALGORITHM_MAP: AlgorithmMap = {
|
||||
A256GCM: { name: 'AES-GCM', length: 256 },
|
||||
A128CBC: { name: 'AES-CBC', length: 128 },
|
||||
};
|
||||
|
||||
type Jwa = keyof AlgorithmMap;
|
||||
type Algorithms = AlgorithmMap[Jwa];
|
||||
|
||||
export type Key<TAlgorithm extends Algorithms = Algorithms, TExtractable extends CryptoKey['extractable'] = true> = IKey<
|
||||
TAlgorithm,
|
||||
TExtractable,
|
||||
'secret',
|
||||
['encrypt', 'decrypt']
|
||||
>;
|
||||
|
||||
export type Jwk<TJwa extends Jwa = Jwa> = {
|
||||
kty: 'oct';
|
||||
k: string;
|
||||
key_ops: ['encrypt', 'decrypt'] | ['decrypt', 'encrypt'];
|
||||
ext: true;
|
||||
alg: TJwa;
|
||||
};
|
||||
|
||||
type AesEncryptedContent = {
|
||||
iv: Uint8Array<ArrayBuffer>;
|
||||
ciphertext: Uint8Array<ArrayBuffer>;
|
||||
};
|
||||
|
||||
export const importKey = <const TJwa extends Jwa>(jwk: Jwk<TJwa>): Promise<Key<AlgorithmMap[(typeof jwk)['alg']]>> => {
|
||||
return importJwk(jwk, ALGORITHM_MAP[jwk.alg], true, ['encrypt', 'decrypt']);
|
||||
};
|
||||
|
||||
export const exportJwk = <TAlgorithm extends Algorithms>(key: Key<TAlgorithm>): Promise<Exported<'jwk', Key<TAlgorithm>>> => {
|
||||
return exportKey('jwk', key);
|
||||
};
|
||||
|
||||
export const generate = (): Promise<Key<{ name: 'AES-GCM'; length: 256 }>> => {
|
||||
return generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
|
||||
};
|
||||
|
||||
export const decrypt = async (key: Key, content: AesEncryptedContent): Promise<string> => {
|
||||
const decrypted = await decryptBuffer(
|
||||
key,
|
||||
{ name: key.algorithm.name, iv: content.iv } satisfies AesGcmParams | AesCbcParams,
|
||||
content.ciphertext,
|
||||
);
|
||||
return new TextDecoder().decode(decrypted);
|
||||
};
|
||||
|
||||
export const encrypt = async (key: Key, plaintext: Uint8Array<ArrayBuffer>): Promise<AesEncryptedContent> => {
|
||||
const ivLength = key.algorithm.name === 'AES-GCM' ? 12 : 16;
|
||||
const iv = getRandomValues(new Uint8Array(ivLength));
|
||||
const ciphertext = await encryptBuffer(key, { name: key.algorithm.name, iv }, plaintext);
|
||||
return { iv, ciphertext: new Uint8Array(ciphertext) };
|
||||
};
|
||||
49
apps/meteor/client/lib/e2ee/crypto/pbkdf2.spec.ts
Normal file
49
apps/meteor/client/lib/e2ee/crypto/pbkdf2.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { importBaseKey, derive, importKey } from './pbkdf2';
|
||||
|
||||
describe('pbkdf2', () => {
|
||||
it('should import a base key', async () => {
|
||||
const keyData = new Uint8Array([1, 2, 3, 4, 5]);
|
||||
const baseKey = await importBaseKey(keyData);
|
||||
expect<'PBKDF2'>(baseKey.algorithm.name).toBe('PBKDF2');
|
||||
expect<false>(baseKey.extractable).toBe(false);
|
||||
expect<'secret'>(baseKey.type).toBe('secret');
|
||||
expect<1>(baseKey.usages.length).toBe(1);
|
||||
expect<'deriveBits'>(baseKey.usages[0]).toBe('deriveBits');
|
||||
});
|
||||
|
||||
it('should derive bits from a base key', async () => {
|
||||
const keyData = new Uint8Array([1, 2, 3, 4, 5]);
|
||||
const salt = new Uint8Array([5, 4, 3, 2, 1]);
|
||||
const iterations = 1000;
|
||||
const baseKey = await importBaseKey(keyData);
|
||||
const derivedBits = await derive(baseKey, { salt, iterations });
|
||||
expect<false>(derivedBits.detached).toBe(false);
|
||||
expect<false>(derivedBits.resizable).toBe(false);
|
||||
expect<32>(derivedBits.byteLength).toBe(32);
|
||||
expect<32>(derivedBits.maxByteLength).toBe(32);
|
||||
expect<() => never>(() => derivedBits.resize(32)).toThrow();
|
||||
});
|
||||
|
||||
it('should import a derived key for AES-CBC', async () => {
|
||||
const baseKey = await importBaseKey(new Uint8Array([1, 2, 3, 4, 5]));
|
||||
const derivedBits = await derive(baseKey, { salt: new Uint8Array([5, 4, 3, 2, 1]), iterations: 1000 });
|
||||
const derivedKey = await importKey(derivedBits, { name: 'AES-CBC', length: 256 });
|
||||
expect<256>(derivedKey.algorithm.length).toBe(256);
|
||||
expect<'AES-CBC'>(derivedKey.algorithm.name).toBe('AES-CBC');
|
||||
expect<false>(derivedKey.extractable).toBe(false);
|
||||
expect<'secret'>(derivedKey.type).toBe('secret');
|
||||
expect<1>(derivedKey.usages.length).toBe(1);
|
||||
expect<['decrypt']>(derivedKey.usages).toEqual(['decrypt']);
|
||||
});
|
||||
|
||||
it('should import a derived key for AES-GCM', async () => {
|
||||
const baseKey = await importBaseKey(new Uint8Array([1, 2, 3, 4, 5]));
|
||||
const derivedBits = await derive(baseKey, { salt: new Uint8Array([5, 4, 3, 2, 1]), iterations: 1000 });
|
||||
const derivedKey = await importKey(derivedBits, { name: 'AES-GCM', length: 256 });
|
||||
expect<256>(derivedKey.algorithm.length).toBe(256);
|
||||
expect<'AES-GCM'>(derivedKey.algorithm.name).toBe('AES-GCM');
|
||||
expect<false>(derivedKey.extractable).toBe(false);
|
||||
expect<'secret'>(derivedKey.type).toBe('secret');
|
||||
expect<['encrypt', 'decrypt'] | ['decrypt', 'encrypt']>(derivedKey.usages).toEqual(['encrypt', 'decrypt']);
|
||||
});
|
||||
});
|
||||
87
apps/meteor/client/lib/e2ee/crypto/pbkdf2.ts
Normal file
87
apps/meteor/client/lib/e2ee/crypto/pbkdf2.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { importRaw, getRandomValues, decryptBuffer, encryptBuffer, deriveBits, type IKey } from './shared';
|
||||
|
||||
type Algorithms = { name: 'AES-GCM'; length: 256 } | { name: 'AES-CBC'; length: 256 };
|
||||
|
||||
export type DerivedKey<TAlgorithm extends Algorithms = Algorithms> = IKey<
|
||||
TAlgorithm,
|
||||
false,
|
||||
'secret',
|
||||
TAlgorithm['name'] extends 'AES-CBC' ? ['decrypt'] : ['encrypt', 'decrypt']
|
||||
>;
|
||||
|
||||
export type Options = {
|
||||
salt: Uint8Array<ArrayBuffer>;
|
||||
iterations: number;
|
||||
};
|
||||
|
||||
export type EncryptedContent = {
|
||||
iv: Uint8Array<ArrayBuffer>;
|
||||
ciphertext: Uint8Array<ArrayBuffer>;
|
||||
};
|
||||
|
||||
type Narrow<T, U extends { [P in keyof T]?: T[P] }> = {
|
||||
[P in keyof T]: P extends keyof U ? U[P] : T[P];
|
||||
};
|
||||
|
||||
export type BaseKey = IKey<
|
||||
{
|
||||
readonly name: 'PBKDF2';
|
||||
},
|
||||
false,
|
||||
'secret',
|
||||
['deriveBits']
|
||||
>;
|
||||
|
||||
export const importBaseKey = async (keyData: Uint8Array<ArrayBuffer>): Promise<BaseKey> => {
|
||||
const baseKey = await importRaw(keyData, { name: 'PBKDF2' }, false, ['deriveBits']);
|
||||
return baseKey;
|
||||
};
|
||||
|
||||
type Throws<F> = F extends (...args: infer TArgs) => infer TRet ? (...args: TArgs) => TRet & never : never;
|
||||
|
||||
type FixedSizeArrayBuffer<N extends number> = Narrow<
|
||||
ArrayBuffer,
|
||||
{
|
||||
resize: Throws<ArrayBuffer['resize']>;
|
||||
readonly byteLength: N;
|
||||
get maxByteLength(): N;
|
||||
get resizable(): false;
|
||||
get detached(): false;
|
||||
}
|
||||
>;
|
||||
|
||||
export type DerivedBits = FixedSizeArrayBuffer<32>;
|
||||
|
||||
export const derive = async (key: BaseKey, options: Options): Promise<DerivedBits> => {
|
||||
const bits = await deriveBits(
|
||||
{ name: key.algorithm.name, hash: 'SHA-256', salt: options.salt, iterations: options.iterations },
|
||||
key,
|
||||
256,
|
||||
);
|
||||
return bits as DerivedBits;
|
||||
};
|
||||
|
||||
export const importKey = async <T extends Algorithms>(derivedBits: DerivedBits, algorithm: T): Promise<DerivedKey<T>> => {
|
||||
const usages: ['decrypt'] | ['encrypt', 'decrypt'] = algorithm.name === 'AES-CBC' ? ['decrypt'] : ['encrypt', 'decrypt'];
|
||||
const key = await importRaw(derivedBits, algorithm satisfies AesKeyGenParams, false, usages);
|
||||
return key as DerivedKey<T>;
|
||||
};
|
||||
|
||||
export const decrypt = async (key: DerivedKey, content: EncryptedContent): Promise<Uint8Array<ArrayBuffer>> => {
|
||||
const decrypted = await decryptBuffer(
|
||||
key,
|
||||
{ name: key.algorithm.name, iv: content.iv } satisfies AesCbcParams | AesGcmParams,
|
||||
content.ciphertext,
|
||||
);
|
||||
return new Uint8Array(decrypted);
|
||||
};
|
||||
|
||||
export const encrypt = async (
|
||||
key: DerivedKey<{ name: 'AES-GCM'; length: 256 }>,
|
||||
data: Uint8Array<ArrayBuffer>,
|
||||
): Promise<EncryptedContent> => {
|
||||
// Always use AES-GCM for new data
|
||||
const iv = getRandomValues(new Uint8Array(12));
|
||||
const ciphertext = await encryptBuffer(key, { name: 'AES-GCM', iv }, data);
|
||||
return { iv, ciphertext: new Uint8Array(ciphertext) };
|
||||
};
|
||||
65
apps/meteor/client/lib/e2ee/crypto/rsa.spec.ts
Normal file
65
apps/meteor/client/lib/e2ee/crypto/rsa.spec.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { generate, decrypt, encrypt, exportPublicKey, exportPrivateKey, importPrivateKey, importPublicKey, type KeyPair } from './rsa';
|
||||
|
||||
describe('rsa', () => {
|
||||
let keyPair: KeyPair;
|
||||
|
||||
beforeAll(async () => {
|
||||
keyPair = await generate();
|
||||
});
|
||||
|
||||
it('generate a key pair with correct properties', async () => {
|
||||
expect<'RSA-OAEP'>(keyPair.publicKey.algorithm.name).toBe('RSA-OAEP');
|
||||
expect<2048>(keyPair.publicKey.algorithm.modulusLength).toBe(2048);
|
||||
expect<true>(keyPair.publicKey.extractable).toBe(true);
|
||||
expect<'public'>(keyPair.publicKey.type).toBe('public');
|
||||
expect<1>(keyPair.publicKey.usages.length).toBe(1);
|
||||
expect<['encrypt']>(keyPair.publicKey.usages).toEqual(['encrypt']);
|
||||
|
||||
expect<'RSA-OAEP'>(keyPair.privateKey.algorithm.name).toBe('RSA-OAEP');
|
||||
expect<2048>(keyPair.privateKey.algorithm.modulusLength).toBe(2048);
|
||||
expect<true>(keyPair.privateKey.extractable).toBe(true);
|
||||
expect<'private'>(keyPair.privateKey.type).toBe('private');
|
||||
expect<1>(keyPair.privateKey.usages.length).toBe(1);
|
||||
expect<['decrypt']>(keyPair.privateKey.usages).toEqual(['decrypt']);
|
||||
});
|
||||
|
||||
it('should encrypt and decrypt data correctly', async () => {
|
||||
const plaintext = new TextEncoder().encode('Hello, RSA-OAEP!');
|
||||
const ciphertext = await encrypt(keyPair.publicKey, plaintext);
|
||||
const decrypted = await decrypt(keyPair.privateKey, ciphertext);
|
||||
expect(new TextDecoder().decode(decrypted)).toBe('Hello, RSA-OAEP!');
|
||||
});
|
||||
|
||||
it('should export and re-import the keys correctly', async () => {
|
||||
const publicJwk = await exportPublicKey(keyPair.publicKey);
|
||||
const privateJwk = await exportPrivateKey(keyPair.privateKey);
|
||||
|
||||
expect<'RSA-OAEP-256'>(publicJwk.alg).toBe('RSA-OAEP-256');
|
||||
expect<'RSA'>(publicJwk.kty).toBe('RSA');
|
||||
expect<true>(publicJwk.ext).toBe(true);
|
||||
expect<'AQAB'>(publicJwk.e).toBe('AQAB');
|
||||
|
||||
expect<'RSA-OAEP-256'>(privateJwk.alg).toBe('RSA-OAEP-256');
|
||||
expect<'RSA'>(privateJwk.kty).toBe('RSA');
|
||||
expect<true>(privateJwk.ext).toBe(true);
|
||||
expect<'AQAB'>(privateJwk.e).toBe('AQAB');
|
||||
|
||||
expect(privateJwk.n).toEqual(publicJwk.n);
|
||||
|
||||
const importedPublicKey = await importPublicKey(publicJwk);
|
||||
expect<2048>(importedPublicKey.algorithm.modulusLength).toBe(2048);
|
||||
expect<'RSA-OAEP'>(importedPublicKey.algorithm.name).toBe('RSA-OAEP');
|
||||
expect<true>(importedPublicKey.extractable).toBe(true);
|
||||
expect<'public'>(importedPublicKey.type).toBe('public');
|
||||
expect<1>(importedPublicKey.usages.length).toBe(1);
|
||||
expect<['encrypt']>(importedPublicKey.usages).toEqual(['encrypt']);
|
||||
|
||||
const importedPrivateKey = await importPrivateKey(privateJwk);
|
||||
expect<2048>(importedPrivateKey.algorithm.modulusLength).toBe(2048);
|
||||
expect<'RSA-OAEP'>(importedPrivateKey.algorithm.name).toBe('RSA-OAEP');
|
||||
expect<true>(importedPrivateKey.extractable).toBe(true);
|
||||
expect<'private'>(importedPrivateKey.type).toBe('private');
|
||||
expect<1>(importedPrivateKey.usages.length).toBe(1);
|
||||
expect<['decrypt']>(importedPrivateKey.usages).toEqual(['decrypt']);
|
||||
});
|
||||
});
|
||||
109
apps/meteor/client/lib/e2ee/crypto/rsa.ts
Normal file
109
apps/meteor/client/lib/e2ee/crypto/rsa.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { generateKeyPair, exportKey, importJwk, type IKeyPair, encryptBuffer, decryptBuffer } from './shared';
|
||||
|
||||
export type KeyPair = IKeyPair<
|
||||
{
|
||||
readonly name: 'RSA-OAEP';
|
||||
readonly modulusLength: 2048;
|
||||
readonly publicExponent: Uint8Array<ArrayBuffer>;
|
||||
readonly hash: {
|
||||
readonly name: 'SHA-256';
|
||||
};
|
||||
},
|
||||
true,
|
||||
['encrypt', 'decrypt']
|
||||
>;
|
||||
|
||||
export type PublicKey = KeyPair['publicKey'];
|
||||
|
||||
export type PrivateKey = KeyPair['privateKey'];
|
||||
|
||||
export const generate = async (): Promise<KeyPair> => {
|
||||
const keyPair = await generateKeyPair(
|
||||
{
|
||||
name: 'RSA-OAEP',
|
||||
modulusLength: 2048,
|
||||
publicExponent: new Uint8Array([0x01, 0x00, 0x01]),
|
||||
hash: { name: 'SHA-256' },
|
||||
},
|
||||
true,
|
||||
['encrypt', 'decrypt'],
|
||||
);
|
||||
|
||||
return keyPair;
|
||||
};
|
||||
|
||||
type Base64Url = string;
|
||||
|
||||
export interface IPublicJwk {
|
||||
kty: 'RSA';
|
||||
alg: 'RSA-OAEP-256';
|
||||
e: 'AQAB';
|
||||
ext: true;
|
||||
key_ops: ['encrypt'];
|
||||
n: Base64Url;
|
||||
}
|
||||
|
||||
export interface IPrivateJwk {
|
||||
kty: 'RSA';
|
||||
alg: 'RSA-OAEP-256';
|
||||
e: 'AQAB';
|
||||
ext: true;
|
||||
d: Base64Url;
|
||||
dp: Base64Url;
|
||||
dq: Base64Url;
|
||||
key_ops: ['decrypt'];
|
||||
n: Base64Url;
|
||||
p: Base64Url;
|
||||
q: Base64Url;
|
||||
qi: Base64Url;
|
||||
}
|
||||
|
||||
export const exportPublicKey = async (key: PublicKey): Promise<IPublicJwk> => {
|
||||
const jwk = await exportKey('jwk', key);
|
||||
return jwk as IPublicJwk;
|
||||
};
|
||||
|
||||
export const exportPrivateKey = async (key: PrivateKey): Promise<IPrivateJwk> => {
|
||||
const jwk = await exportKey('jwk', key);
|
||||
return jwk as IPrivateJwk;
|
||||
};
|
||||
|
||||
export const importPrivateKey = async (keyData: IPrivateJwk): Promise<PrivateKey> => {
|
||||
const key = await importJwk(
|
||||
keyData,
|
||||
{
|
||||
name: 'RSA-OAEP',
|
||||
hash: { name: 'SHA-256' },
|
||||
modulusLength: 2048,
|
||||
publicExponent: new Uint8Array([0x01, 0x00, 0x01]),
|
||||
},
|
||||
true,
|
||||
['decrypt'],
|
||||
);
|
||||
return key as PrivateKey;
|
||||
};
|
||||
|
||||
export const importPublicKey = async (keyData: IPublicJwk): Promise<PublicKey> => {
|
||||
const key = await importJwk(
|
||||
keyData,
|
||||
{
|
||||
name: 'RSA-OAEP',
|
||||
hash: { name: 'SHA-256' },
|
||||
modulusLength: 2048,
|
||||
publicExponent: new Uint8Array([0x01, 0x00, 0x01]),
|
||||
},
|
||||
true,
|
||||
['encrypt'],
|
||||
);
|
||||
return key as PublicKey;
|
||||
};
|
||||
|
||||
export const encrypt = async (key: PublicKey, data: BufferSource): Promise<Uint8Array<ArrayBuffer>> => {
|
||||
const encrypted = await encryptBuffer(key, { name: key.algorithm.name }, data);
|
||||
return new Uint8Array(encrypted);
|
||||
};
|
||||
|
||||
export const decrypt = async (key: PrivateKey, data: BufferSource): Promise<Uint8Array<ArrayBuffer>> => {
|
||||
const decrypted = await decryptBuffer(key, { name: key.algorithm.name }, data);
|
||||
return new Uint8Array(decrypted);
|
||||
};
|
||||
141
apps/meteor/client/lib/e2ee/crypto/shared.spec.ts
Normal file
141
apps/meteor/client/lib/e2ee/crypto/shared.spec.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { generateKey, generateKeyPair, exportKey, importJwk, encryptBuffer, decryptBuffer, getRandomValues } from './shared';
|
||||
|
||||
describe('Shared Crypto Functions', () => {
|
||||
describe('generateKey', () => {
|
||||
it('should generate an AES key with correct properties', async () => {
|
||||
const key = await generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
|
||||
expect<'AES-GCM'>(key.algorithm.name).toBe('AES-GCM');
|
||||
expect<256>(key.algorithm.length).toBe(256);
|
||||
expect<true>(key.extractable).toBe(true);
|
||||
expect<'secret'>(key.type).toBe('secret');
|
||||
expect<2>(key.usages.length).toBe(2);
|
||||
expect<['encrypt', 'decrypt'] | ['decrypt', 'encrypt']>(key.usages).toEqual(expect.arrayContaining(['encrypt', 'decrypt']));
|
||||
});
|
||||
|
||||
it('should export a key to JWK format', async () => {
|
||||
const key = await generateKey({ name: 'AES-CBC', length: 128 }, true, ['encrypt']);
|
||||
const exportedKey = await exportKey('jwk', key);
|
||||
expect(Object.keys(exportedKey)).toHaveLength(5);
|
||||
expect<true>(exportedKey.ext).toBe(true);
|
||||
expect<'oct'>(exportedKey.kty).toBe('oct');
|
||||
expect<string>(exportedKey.k).toHaveLength(22); // 128 bits in base64url
|
||||
expect<`A128CBC`>(exportedKey.alg).toBe('A128CBC');
|
||||
expect<['encrypt']>(exportedKey.key_ops).toEqual(['encrypt']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateKeyPair', () => {
|
||||
it('should generate an RSA key pair with correct properties', async () => {
|
||||
const keyPair = await generateKeyPair(
|
||||
{
|
||||
name: 'RSA-OAEP',
|
||||
modulusLength: 2048,
|
||||
publicExponent: new Uint8Array([0x01, 0x00, 0x01]),
|
||||
hash: { name: 'SHA-256' },
|
||||
},
|
||||
true,
|
||||
['encrypt', 'decrypt'],
|
||||
);
|
||||
|
||||
expect<'RSA-OAEP'>(keyPair.publicKey.algorithm.name).toBe('RSA-OAEP');
|
||||
expect<2048>(keyPair.publicKey.algorithm.modulusLength).toBe(2048);
|
||||
expect<true>(keyPair.publicKey.extractable).toBe(true);
|
||||
expect<'public'>(keyPair.publicKey.type).toBe('public');
|
||||
expect<1>(keyPair.publicKey.usages.length).toBe(1);
|
||||
expect<['encrypt']>(keyPair.publicKey.usages).toEqual(['encrypt']);
|
||||
|
||||
expect<'RSA-OAEP'>(keyPair.privateKey.algorithm.name).toBe('RSA-OAEP');
|
||||
expect<2048>(keyPair.privateKey.algorithm.modulusLength).toBe(2048);
|
||||
expect<true>(keyPair.privateKey.extractable).toBe(true);
|
||||
expect<'private'>(keyPair.privateKey.type).toBe('private');
|
||||
expect<1>(keyPair.privateKey.usages.length).toBe(1);
|
||||
expect<['decrypt']>(keyPair.privateKey.usages).toEqual(['decrypt']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('importKey', () => {
|
||||
it('should import an AES key from JWK format', async () => {
|
||||
const key = await importJwk(
|
||||
{
|
||||
kty: 'oct' as const,
|
||||
k: 'qb8In0Rpa9nwSusvxxDcbQ',
|
||||
key_ops: ['encrypt', 'decrypt'] as const,
|
||||
ext: true,
|
||||
alg: 'A128CBC' as const,
|
||||
},
|
||||
{ name: 'AES-CBC', length: 128 },
|
||||
true,
|
||||
['encrypt', 'decrypt'],
|
||||
);
|
||||
expect<128>(key.algorithm.length).toBe(128);
|
||||
expect<'AES-CBC'>(key.algorithm.name).toBe('AES-CBC');
|
||||
expect<true>(key.extractable).toBe(true);
|
||||
expect<'secret'>(key.type).toBe('secret');
|
||||
expect<2>(key.usages.length).toBe(2);
|
||||
expect<['encrypt', 'decrypt'] | ['decrypt', 'encrypt']>(key.usages).toEqual(expect.arrayContaining(['encrypt', 'decrypt']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('exportKey', () => {
|
||||
it('should export an RSA public key to JWK format', async () => {
|
||||
const keyPair = await generateKeyPair(
|
||||
{
|
||||
name: 'RSA-OAEP',
|
||||
modulusLength: 2048,
|
||||
publicExponent: new Uint8Array([0x01, 0x00, 0x01]),
|
||||
hash: { name: 'SHA-256' },
|
||||
},
|
||||
true,
|
||||
['encrypt', 'decrypt'],
|
||||
);
|
||||
const exportedKey = await exportKey('jwk', keyPair.publicKey);
|
||||
expect(Object.keys(exportedKey).toSorted()).toEqual(['alg', 'e', 'ext', 'key_ops', 'kty', 'n'].toSorted());
|
||||
expect<true>(exportedKey.ext).toBe(true);
|
||||
expect<'RSA'>(exportedKey.kty).toBe('RSA');
|
||||
expect<`RSA-OAEP-256`>(exportedKey.alg).toBe('RSA-OAEP-256');
|
||||
expect<['encrypt']>(exportedKey.key_ops).toEqual(['encrypt']);
|
||||
expect<string>(exportedKey.n).toBeDefined();
|
||||
expect<string>(exportedKey.e).toBeDefined();
|
||||
});
|
||||
|
||||
it('should export an RSA private key to JWK format', async () => {
|
||||
const keyPair = await generateKeyPair(
|
||||
{
|
||||
name: 'RSA-OAEP',
|
||||
modulusLength: 2048,
|
||||
publicExponent: new Uint8Array([0x01, 0x00, 0x01]),
|
||||
hash: { name: 'SHA-256' },
|
||||
},
|
||||
true,
|
||||
['encrypt', 'decrypt'],
|
||||
);
|
||||
const exportedKey = await exportKey('jwk', keyPair.privateKey);
|
||||
expect(Object.keys(exportedKey).toSorted()).toEqual(
|
||||
['alg', 'd', 'dp', 'dq', 'e', 'ext', 'key_ops', 'kty', 'n', 'p', 'q', 'qi'].toSorted(),
|
||||
);
|
||||
expect<true>(exportedKey.ext).toBe(true);
|
||||
expect<'RSA'>(exportedKey.kty).toBe('RSA');
|
||||
expect<`RSA-OAEP-256`>(exportedKey.alg).toBe('RSA-OAEP-256');
|
||||
expect<['decrypt']>(exportedKey.key_ops).toEqual(['decrypt']);
|
||||
expect<string>(exportedKey.n).toBeDefined();
|
||||
expect<string>(exportedKey.e).toBeDefined();
|
||||
expect<string>(exportedKey.d).toBeDefined();
|
||||
expect<string>(exportedKey.p).toBeDefined();
|
||||
expect<string>(exportedKey.q).toBeDefined();
|
||||
expect<string>(exportedKey.dp).toBeDefined();
|
||||
expect<string>(exportedKey.dq).toBeDefined();
|
||||
expect<string>(exportedKey.qi).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('encryptBuffer', () => {
|
||||
it('should encrypt and decrypt data correctly', async () => {
|
||||
const key = await generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
|
||||
const plaintext = new TextEncoder().encode('Test encryption');
|
||||
const iv = getRandomValues(new Uint8Array(12));
|
||||
const ciphertext = await encryptBuffer(key, { name: 'AES-GCM', iv }, plaintext);
|
||||
const decrypted = await decryptBuffer(key, { name: 'AES-GCM', iv }, ciphertext);
|
||||
expect(new TextDecoder().decode(decrypted)).toBe('Test encryption');
|
||||
});
|
||||
});
|
||||
});
|
||||
198
apps/meteor/client/lib/e2ee/crypto/shared.ts
Normal file
198
apps/meteor/client/lib/e2ee/crypto/shared.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
const { subtle } = crypto;
|
||||
export const randomUUID = () => crypto.randomUUID();
|
||||
export const getRandomValues = crypto.getRandomValues.bind(crypto);
|
||||
|
||||
interface IAesGcmParams extends AesGcmParams {
|
||||
name: 'AES-GCM';
|
||||
}
|
||||
|
||||
interface IAesCbcParams extends AesCbcParams {
|
||||
name: 'AES-CBC';
|
||||
}
|
||||
|
||||
interface IAesCtrParams extends AesCtrParams {
|
||||
name: 'AES-CTR';
|
||||
}
|
||||
|
||||
interface IRsaOaepParams extends RsaOaepParams {
|
||||
name: 'RSA-OAEP';
|
||||
}
|
||||
|
||||
type ParamsMap = {
|
||||
'AES-GCM': IAesGcmParams;
|
||||
'AES-CTR': IAesCtrParams;
|
||||
'AES-CBC': IAesCbcParams;
|
||||
'RSA-OAEP': IRsaOaepParams;
|
||||
};
|
||||
|
||||
type ParamsOf<TKey extends IKey> = TKey['algorithm'] extends { name: infer TName extends keyof ParamsMap } ? ParamsMap[TName] : never;
|
||||
type HasUsage<TKey extends IKey, TUsage extends KeyUsage> = TUsage extends TKey['usages'][number]
|
||||
? TKey
|
||||
: TKey & `The provided key cannot be used for ${TUsage}`;
|
||||
|
||||
export const encryptBuffer = <TKey extends IKey, TParams extends ParamsOf<TKey>>(
|
||||
key: HasUsage<TKey, 'encrypt'>,
|
||||
params: TParams,
|
||||
data: BufferSource,
|
||||
): Promise<ArrayBuffer> => subtle.encrypt(params, key, data);
|
||||
export const decryptBuffer = <TKey extends IKey>(
|
||||
key: HasUsage<TKey, 'decrypt'>,
|
||||
params: ParamsOf<TKey>,
|
||||
data: BufferSource,
|
||||
): Promise<ArrayBuffer> => subtle.decrypt(params, key, data);
|
||||
export const deriveBits = (...args: Parameters<typeof subtle.deriveBits>) => subtle.deriveBits(...args);
|
||||
|
||||
type AesParams = {
|
||||
name: 'AES-CBC' | 'AES-GCM' | 'AES-CTR';
|
||||
length: 128 | 256;
|
||||
};
|
||||
|
||||
type ModeOf<T extends `AES-${'CBC' | 'GCM' | 'CTR'}`> = T extends `AES-${infer Mode}` ? Mode : never;
|
||||
|
||||
type Permutations<T> = T extends [infer First]
|
||||
? [First]
|
||||
: T extends [infer First, infer Second]
|
||||
? [First, Second] | [Second, First]
|
||||
: never;
|
||||
|
||||
export interface IKey<
|
||||
TAlgorithm extends CryptoKey['algorithm'] = CryptoKey['algorithm'],
|
||||
TExtractable extends CryptoKey['extractable'] = CryptoKey['extractable'],
|
||||
TType extends CryptoKey['type'] = CryptoKey['type'],
|
||||
TUsages extends CryptoKey['usages'] = CryptoKey['usages'],
|
||||
> extends CryptoKey {
|
||||
readonly algorithm: TAlgorithm;
|
||||
readonly extractable: TExtractable;
|
||||
readonly type: TType;
|
||||
readonly usages: Permutations<TUsages>;
|
||||
}
|
||||
|
||||
export async function generateKey<
|
||||
const T extends AesParams,
|
||||
const TExtractable extends boolean = boolean,
|
||||
const TUsages extends KeyUsage[] = KeyUsage[],
|
||||
>(algorithm: T, extractable: TExtractable, keyUsages: TUsages): Promise<IKey<T, TExtractable, 'secret', TUsages>> {
|
||||
const key = await subtle.generateKey(algorithm, extractable, keyUsages);
|
||||
return key as IKey<T, TExtractable, 'secret', TUsages>;
|
||||
}
|
||||
|
||||
type Filter<TItem extends KeyUsage, TArray extends KeyUsage[], Out extends KeyUsage[] = []> = TArray extends [
|
||||
infer First extends KeyUsage,
|
||||
...infer Rest extends KeyUsage[],
|
||||
]
|
||||
? First extends TItem
|
||||
? Filter<TItem, Rest, [...Out, First]>
|
||||
: Filter<TItem, Rest, Out>
|
||||
: Out;
|
||||
|
||||
export interface IKeyPair<
|
||||
T extends KeyAlgorithm = KeyAlgorithm,
|
||||
TExtractable extends boolean = boolean,
|
||||
TUsages extends KeyUsage[] = KeyUsage[],
|
||||
> extends CryptoKeyPair {
|
||||
publicKey: IKey<T, TExtractable, 'public', Filter<'encrypt', TUsages>>;
|
||||
privateKey: IKey<T, TExtractable, 'private', Filter<'decrypt', TUsages>>;
|
||||
}
|
||||
|
||||
type RsaParams = {
|
||||
name: 'RSA-OAEP';
|
||||
modulusLength: 2048;
|
||||
publicExponent: Uint8Array<ArrayBuffer>;
|
||||
hash: { name: 'SHA-256' };
|
||||
};
|
||||
|
||||
export async function generateKeyPair<
|
||||
const T extends RsaParams,
|
||||
const TExtractable extends boolean = boolean,
|
||||
const TUsages extends KeyUsage[] = KeyUsage[],
|
||||
>(algorithm: T, extractable: TExtractable, keyUsages: TUsages): Promise<IKeyPair<T, TExtractable, TUsages>> {
|
||||
const keyPair = await subtle.generateKey(algorithm, extractable, keyUsages);
|
||||
return keyPair as IKeyPair<T, TExtractable, TUsages>;
|
||||
}
|
||||
|
||||
type KeyToJwk<T extends IKey> = T['extractable'] extends false
|
||||
? never
|
||||
: T['algorithm'] extends AesParams
|
||||
? {
|
||||
kty: 'oct';
|
||||
alg: `A${T['algorithm']['length']}${ModeOf<T['algorithm']['name']>}`;
|
||||
ext: true;
|
||||
k: string;
|
||||
key_ops: T['usages'];
|
||||
}
|
||||
: [T['algorithm'], T['type']] extends [RsaParams, 'public']
|
||||
? {
|
||||
kty: 'RSA';
|
||||
alg: 'RSA-OAEP-256';
|
||||
e: string;
|
||||
ext: true;
|
||||
key_ops: T['usages'];
|
||||
n: string;
|
||||
}
|
||||
: [T['algorithm'], T['type']] extends [RsaParams, 'private']
|
||||
? {
|
||||
kty: 'RSA';
|
||||
alg: 'RSA-OAEP-256';
|
||||
e: string;
|
||||
ext: true;
|
||||
d: string;
|
||||
dp: string;
|
||||
dq: string;
|
||||
key_ops: T['usages'];
|
||||
n: string;
|
||||
p: string;
|
||||
q: string;
|
||||
qi: string;
|
||||
}
|
||||
: never;
|
||||
|
||||
export type Exported<TFormat extends KeyFormat, TKey extends IKey = IKey> = TFormat extends 'jwk' ? KeyToJwk<TKey> : ArrayBuffer;
|
||||
|
||||
export async function exportKey<const TFormat extends KeyFormat = KeyFormat, const TKey extends IKey = IKey>(
|
||||
format: TFormat,
|
||||
key: TKey,
|
||||
): Promise<Exported<TFormat, TKey>> {
|
||||
const exportedKey = await subtle.exportKey(format, key);
|
||||
return exportedKey as Exported<TFormat, TKey>;
|
||||
}
|
||||
|
||||
type KtyParams = {
|
||||
oct: AesParams;
|
||||
RSA: RsaParams;
|
||||
};
|
||||
|
||||
export async function importJwk<
|
||||
const TKeyData extends JsonWebKey,
|
||||
const TAlgorithm extends (TKeyData extends { kty: infer Kty extends keyof KtyParams } ? KtyParams[Kty] : KeyAlgorithm),
|
||||
const TExtractable extends boolean = boolean,
|
||||
const TUsages extends KeyUsage[] = KeyUsage[],
|
||||
const TKeyType extends KeyType = TKeyData extends { kty: 'oct' }
|
||||
? 'secret'
|
||||
: TKeyData extends { kty: 'RSA'; key_ops: [infer Op] }
|
||||
? Op extends 'encrypt'
|
||||
? 'public'
|
||||
: 'private'
|
||||
: KeyType,
|
||||
>(
|
||||
jwk: TKeyData,
|
||||
algorithm: TAlgorithm,
|
||||
extractable: TExtractable,
|
||||
keyUsages: TUsages,
|
||||
): Promise<IKey<TAlgorithm, TExtractable, TKeyType, TUsages>> {
|
||||
const key = await subtle.importKey('jwk', jwk, algorithm, extractable, keyUsages);
|
||||
return key as IKey<TAlgorithm, TExtractable, TKeyType, TUsages>;
|
||||
}
|
||||
|
||||
export async function importRaw<
|
||||
const TAlgorithm extends KeyAlgorithm = KeyAlgorithm,
|
||||
const TExtractable extends boolean = boolean,
|
||||
const TUsages extends KeyUsage[] = KeyUsage[],
|
||||
>(
|
||||
rawKey: BufferSource,
|
||||
algorithm: TAlgorithm,
|
||||
extractable: TExtractable,
|
||||
keyUsages: TUsages,
|
||||
): Promise<IKey<TAlgorithm, TExtractable, 'secret', TUsages>> {
|
||||
const key = await subtle.importKey('raw', rawKey, algorithm, extractable, keyUsages);
|
||||
return key as IKey<TAlgorithm, TExtractable, 'secret', TUsages>;
|
||||
}
|
||||
53
apps/meteor/client/lib/e2ee/helper.ts
Normal file
53
apps/meteor/client/lib/e2ee/helper.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
export async function encryptAESCTR(counter: BufferSource, key: CryptoKey, data: BufferSource) {
|
||||
return crypto.subtle.encrypt({ name: 'AES-CTR', counter, length: 64 }, key, data);
|
||||
}
|
||||
|
||||
export function generateAESCTRKey(): Promise<CryptoKey> {
|
||||
return crypto.subtle.generateKey({ name: 'AES-CTR', length: 256 }, true, ['encrypt', 'decrypt']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates 12 uniformly random words from the word list.
|
||||
*
|
||||
* @remarks
|
||||
* Uses {@link https://en.wikipedia.org/wiki/Rejection_sampling | rejection sampling} to ensure uniform distribution.
|
||||
*
|
||||
* @returns A space-separated passphrase.
|
||||
*/
|
||||
export async function generatePassphrase() {
|
||||
const { wordlist } = await import('./wordList');
|
||||
|
||||
// Number of words in the passphrase
|
||||
const WORD_COUNT = 12;
|
||||
// We use 32-bit random numbers, so the maximum value is 2^32 - 1
|
||||
const MAX_UINT32 = 0xffffffff;
|
||||
|
||||
const range = wordlist.length;
|
||||
const rejectionThreshold = Math.floor(MAX_UINT32 / range) * range;
|
||||
|
||||
const words: string[] = [];
|
||||
const buf = new Uint32Array(1);
|
||||
|
||||
for (let i = 0; i < WORD_COUNT; i++) {
|
||||
let v: number;
|
||||
do {
|
||||
crypto.getRandomValues(buf);
|
||||
v = buf[0];
|
||||
} while (v >= rejectionThreshold);
|
||||
words.push(wordlist[v % range]);
|
||||
}
|
||||
|
||||
return words.join(' ');
|
||||
}
|
||||
|
||||
export async function createSha256HashFromText(data: string) {
|
||||
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(data));
|
||||
return Array.from(new Uint8Array(hash))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
export async function sha256HashFromArrayBuffer(arrayBuffer: ArrayBuffer) {
|
||||
const hashArray = Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', arrayBuffer)));
|
||||
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
1
apps/meteor/client/lib/e2ee/index.ts
Normal file
1
apps/meteor/client/lib/e2ee/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './rocketchat.e2e';
|
||||
57
apps/meteor/client/lib/e2ee/keychain.spec.ts
Normal file
57
apps/meteor/client/lib/e2ee/keychain.spec.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Keychain } from './keychain';
|
||||
|
||||
describe('Keychain', () => {
|
||||
/**
|
||||
* Calculates the length of a base64-encoded string given the length of the original byte array.
|
||||
*
|
||||
* @param byteLength The length of the byte array to be encoded.
|
||||
* @returns The length of the resulting base64-encoded string.
|
||||
*/
|
||||
const base64Length = (byteLength: number) => {
|
||||
return ((4 * byteLength) / 3 + 3) & ~3;
|
||||
};
|
||||
|
||||
const pwdv1web = {
|
||||
userId: 'userE2EE',
|
||||
persisted:
|
||||
'{"$binary":"JtJhD4JHyNS5dnf5+qj1IxbEGa6bmxlgvX6r0+LhvbnD15KmyTN/HBi0qip4GYJaolVhk7Y5YuB5qT/cRxKv6+2Qe+4Rc6DorInx1+V2tdI2JWkQoVi91GpN7ezy8ghOJ5nbsPn4xkv8jVZU2itAIAuqurKvL1hPUMsGsSnxazC6zEsInv0R/9Y2sdtm5L6ISSjo6iIqUaUEPw0gqt6y54ZSKOdTtI7xlpzJXrNdirrERiXDBj8mbzH2JG9OqfXHZWxm+NtaYLnDuwhN8M9TFj72blBU4c6z4TSb2Gc5aoAkQ9t0tlGZXbjxoa0Y0OT5UqQYVWuqDHPNyzl9ZBz6ZQ735laQZofp5roaH1g2tD9AuBO6iTvbuhwuBGdsbFj79FE5dXXKfcH44enNqL0EYGB81z1+4wuWjhyjnEuC9KXJQNqiCmftO0JQzzEqrrnYpOcFItsJPJ2TNbP2yUuITJzewjkqq4k2tpra/vblNpOnU1SkfT4PzPRk4oarUOQ9w6x8eHpXGXkck22sdPmAKNJTv7YjLSL2yLdGWKV8uxFLDHfagMPQf8e9yK3BknsJfYAU2g3PvwZIBQcrEGPkG75RvTbzFVyjPZPjDcNjvM5X1LiRTEtP9ygkGOhO+Gm+I44l9B/9Lu/mWEtonKhq5xl/aF02vutON1NKVDfmNa2mQ7HwxysKsjRAIyMBfwmVC+ave5V153iQH/3zGSx1HWaNps4IMS6/G7PsOFExTvN8ebSrQBbAdMRWoroOorW8pCIacIII5r42gCJm0ph2hcbQOL8GHbwVhc0pHhuoO7O3575YO4AsmsFueurqqqcVQY9Hbj6L5rL92nU5gphHEhb9kRJN70j6rv0mdlgXOvrXnZ4ZjIiTY+WS3kN9ecKOjVy/E7k9I3BJvPw3E6xt4cEWZsvLOdRD39ENNmUjGdRZHlU6TLleOx/cpzAhCnacyow/mG9Oijumhr8JY9PnnHJiTLBjxbtGbVoOTc4BFqYloZr2i22Luvi7p6Qf3XHtkMo02u3aaYL8u2GniIo4k3swsJzQfuYzC5SJ61i7+tzq9t/LxB1t8JDVUlRhMI20KGPauz/JJOo5ss8gkDi2y/egV3aV1/LUTzg3NWL9sXNtBaVvc2RrAOVn4pjW4BWoy0rKbT2ELfm9jpi0deUt30YwJO4g/zqutWehjj0htJ3Bbha1nzN3JZmRDONawDhao3QzYxwHHmc2VoHtUYWr150FTV7DCYVZ9uhmSxaCBvihe3Q8YSzLi+YS5EF3CgXmAUoE+hKsQk0C0drvO6nmxJTiIplxpOiscNSY0TL6crTzRGCmSLnvHOa/mcnm+8XBB/k1I1tCKjntQsjJMwlZVKP1rhvFNyIJrrnBTRZdrax9O2oBvmSH4drfqgLDmKh+mkO+9NQt3pCTCLZGWFvB3jdk27gSVNC/12uFfZof84uzlPWLN5zyzFZzIn6M5goAL+O910Jk2u7scNSHXejFC0LtYQepFMvpzKF9BY2i1We8hj3f8iDxamQpgA7/Ohl73tJfLZ1ByLlfdCLSdbO2gBYYFxOtt/GzAxfrNMa8nOKV5Vb3FBdA4IkfhQ2QrDKkJL+aBcssOmqNwUcL3C2VA11EWRenKEpwY/daxh8Kfw6nBnV6SWa/HnwKHxoNOYJVYmagh++Y/7ZYkv2uRKBP+DtVa697Yft5nYr82YjlrQA7nBGR5SUtsD/uIjDxRpMrXuFnGg3hsPo1HvTLFFUWeJH50JmAuDCA9tYmOrUVMkP+9g/lprkeHMaIsdrAXMvADeHtSIZzqqvIWUIuyLMbiMJalmsjhObbwo8gt9MNEosQT8dsExxQ4rkf2LhmOtYU7EH6wAwjPWgGUlJ/VDzmhxXQTfdO2yPOV5FfkvqAm3xJsqnf2INuPfwrOn4RWrw2qR36rNfcOGwRI8sloUpV54gkh7pszWZCxYqsbZsnnqeUREIJBqsMxP0tx7cRzC3Rrjc7+9Y5gmiRdPjHIZbTFie/vx8kR9X77y+p3Dzirq9qjACAW+08Z5MdQ2UVxL2lg6niGuq9wIa/TnMVbMPXY76Og8waBjZf6GcI+1DATPRdm9aQLK4Bb/divsOzzW1jyw7toQSfgk0cDpYqIetuixQf5+7f3WX3GXTistduUkSihA6+9HpkEnCykT4uGq1Ek+Tm1K0IkH8pOYc/kwqLqgl5AwrhoAsYwSVOgT2UxM+/pbBpRo9MfJv64OjuBXSKchv+NMcyssMZB674jdGceA=="}',
|
||||
public_key:
|
||||
'{"alg":"RSA-OAEP-256","e":"AQAB","ext":true,"key_ops":["encrypt"],"kty":"RSA","n":"qVLMv2Iwm_Hyhlnh4etNlHEiCXBzJWbqMwOZ6pz_JuZY2HiqbLmSfBtpvwBKZvcmP92BqLl-qZuLV_bJD_11UBS3gR6ykgU-RsTz1L-V8vA2QZEyULP4DqkMcRCV_7WE_sn_ScePgKszx1284gnngct_1Tv37zB6Ifz7gb1THRwAqOGcE2htea4yQEhyX8ZAl_-95DTWLbXqEAuofqDpXMcQo487VezBWIaDdfw2VX0qi6kM-pt03Gx8uMniyAjhK1G8Dro3wgAtz4PNIwOsdXEvWTSyoXLVMsIuZeO9OGdJKXnZFtVEMzXLyQTD1LjXlsM_TF09fbkN41Tz12ojmQ"}',
|
||||
private_key:
|
||||
'{"alg":"RSA-OAEP-256","d":"IpABtkEzPenNwQng105CKD5NndKj1msi_CXMibzhQk37rbg3xXi9w3KPC8th5JGnb5rl6AxxI-rZrytzUD3C8AVCjes3tSG33BdA1FkFITFSSeD6_ck2pbtxDDVAARHK431VDHjdPHz11Ui3kQZHiNGCtwKGMf9Zts1eg1WjfQnQw2ta4-38mwHpq-4Cm_F1brNTTAu5XlHMws4-TDlYhY3nFU2XvoiR2RPDbMddtvXpDZIVo9s7h3jcS4JxHeJd7mWfwcR_Wf0ArRJIhckgPQtTAAjADNpw_HAdERfJyOAJUnxtHkv4uTu_k23qDpPGEi8euFpQ_1UD8B_Z1Rxylw","dp":"OS3zu_VYJZmOXl1dRXxYGP69MR4YQ3TFJ58HFIxvebD060byGHL-mwf0R6-a1hBkHfSeUI9iPipEcjQeevasPqm5CG8eYMvGU2vhsoq1gfY79rsoKjnThCO3XiUbNeM-G9MRKMRa3ooQ8fUVHyEWKFo1ajoFbVHxZuqTAOgrYT8","dq":"yXtWRU1vM5imQJhIZBt5BO1Rfn-koHTvTM3c5QDdPLyNoGTKTyeoT3P9clN6qevJKTyJJTWiwuz8ZECSksh_m9STCY1ry2HqlF2EKdCZnTQzhoJvb6d7547Witc9eh2rBjsILSxVBadLzOFe8opkkQkdkM_gN_Rr3TtXEAo1vn8","e":"AQAB","ext":true,"key_ops":["decrypt"],"kty":"RSA","n":"qVLMv2Iwm_Hyhlnh4etNlHEiCXBzJWbqMwOZ6pz_JuZY2HiqbLmSfBtpvwBKZvcmP92BqLl-qZuLV_bJD_11UBS3gR6ykgU-RsTz1L-V8vA2QZEyULP4DqkMcRCV_7WE_sn_ScePgKszx1284gnngct_1Tv37zB6Ifz7gb1THRwAqOGcE2htea4yQEhyX8ZAl_-95DTWLbXqEAuofqDpXMcQo487VezBWIaDdfw2VX0qi6kM-pt03Gx8uMniyAjhK1G8Dro3wgAtz4PNIwOsdXEvWTSyoXLVMsIuZeO9OGdJKXnZFtVEMzXLyQTD1LjXlsM_TF09fbkN41Tz12ojmQ","p":"0GJaXeKlxgcz6pX0DdwtWG38x9vN2wfLrN3F8N_0stzyPMjMpLGXOdGq1k1V6FROYvLHZsqdCpziwJ3a1PQaGUg2lO-KeBghlbDk4xfYbzSSPhVdwvUT27dysd3-_TsBvNpVCqCLb9Wgl8f0jrrRmRTSztYSLw3ckL939OJoe0M","q":"0AOMQqdGlz0Tm81uqpzCuQcQLMj-IhmPIMuuTnIU55KCmEwmlf0mkgesj-EEBsC1h6ScC5fvznGNvSGqVQAP5ANNZxGiB73q-2YgH3FpuEeHekufl260E_9tgIuqjtCv-eT_cLUhnRNyuP2ZiqRZsBWLuaQYkTubyGRi6izoofM","qi":"FXbIXivKdh0VBgMtLe5f1OjzyrSW_IfIvz8ZM66F4tUTxnNKk5vSb_q2NPyIOVYbdonuVguX-0VO54Ct16k8VdpQSMmUxGbyQAtIck2IzEzpfbRJgn06wiAI3j8q1nRFhrzhfrpJWVyuTiXBgaeOLWBz8fBpjDU7rptmcoU3tZ4"}',
|
||||
passphrase: 'minus mobile dexter forest elvis',
|
||||
};
|
||||
|
||||
test('decrypt v1 private key', async () => {
|
||||
const keychain = new Keychain(pwdv1web.userId);
|
||||
const decrypted = await keychain.decryptKey(pwdv1web.persisted, pwdv1web.passphrase);
|
||||
expect(decrypted).toBe(pwdv1web.private_key);
|
||||
const encrypted = await keychain.encryptKey(decrypted, pwdv1web.passphrase);
|
||||
expect(encrypted.iv).toHaveLength(base64Length(12));
|
||||
});
|
||||
|
||||
const pwdv2web: typeof pwdv1web = {
|
||||
userId: 'BFqA4FAHKX4qEywfJ',
|
||||
persisted:
|
||||
'{"iv":"vO0oF7jr9jGCuBqJ","ciphertext":"b6e8YDZWn+aHUTJOvs7HSPGv9TvXB/s5Dc8fVzJYGdqaoSDKC5s6cIi6TNeSPblJXYQ2dkdLjgv/VzF6q6/E3/y5px90uHzYXDw92QGwjAHW9jsekSKoxnszMD64iB8O2RCFQPepmx15Upuo8NdnyGno+sbp9G4DkKcB2XrAw53V9BVpJixGc+MNWuRe+TyW49CkDL9kdukPPFYzi5p0qtgUoQ1IiqTUYWU1vVbYia76xEKuHrG+xjdXpXeLLu6FewO1xCWUli32ydQHoyty2WDV3ITfddUPIiPSNjspJXnp7cBKuabap4WuwLWfonv25PjZWICYF4l10Q1gESOmR7sOUBrKVBTQvZfPpZmSHhfMfqeDzuODyNTDAvBHavlIcR11xzIHiy/dofTUL8O+ctiQC+P0i3cN7/hrDXfb+mJHtrKGEO9beSbpYsar4nwaWI4irwFgbg6Ltd2spf7W00KFoQEhTfpZum86wcgT0vxaQRbeH5zHvWgPH8aWvc4NmXV/Tcnf9P5rd/mbSl8F6jk2O7ygkZdkDfmUAxOQ9tjfz16KV9zm+Tlx5V276/a2wfiZ73LpNwWA6DS8+3ncN2nhFsjn0Ma+I4gyrRm1jnr7NUsLg+E+A7cEaN60JEf5IOcFmJ94emh945qv3gpQyUYsCnNR/wBSc3bgr/qC9vK3jKObfYKygt5KaMoLE9YBXqyaZ5Sht63d6sIq8GJis01eUx3+90QyOYKulL0nCiSLREZyyv5LPDbVrcJIV63CuRibbVtCyrQAKRqxm8lyw3gCKNRgNDxTWMnPJd4BN4orq1rI1PDM2zADoSjQEEkf9wo/qaHey1sNufOT8pDu/J9+hKlrwyVFc+x/ApGBxQjLK6NPKDBCJvuqwDu17EHxRlfber/KyyJjK/ymo0xLsXgvjxci4tCrtnaxuqeLXP8W5Bm7akvRKjJj1mY9pztP6vIdgwSRNnPQMRrz36s8czd0e0ba/CpUnWfTUfVE3/MgmWry11KmnyAPDs/jekgmKRKVzkyxQxl+vPjgA3rAlEof2UMqmMaIFQOEbIdFRiy9PddPAVDwDZUHpPo8bFWa22P8g9f0F8d9rLwxUoWfIRB2jqwBea553lvwjt+HXYx1sAUqG2cXagXypi4nT2dRrooDoyeZxrOmtEX4oAIs9yeeQzPRdUlJMpYLnhAv9cjuouEN6RXUuyILUb6fTn2EbE/B147SojFwCMUfp4IC6cPjpwi/O7v0cC9OjcE9/YzWYTUGq2SC5CYZ6TQ9SVwmidcAq8lD4O0gLeYd1lKis3W42q9mIScxKf9l49g6v94kecvoWQ2p5WiUItwnHoTGNBZHH0h9fQbDVorZ7gS6/ZdhoDWUHOjDwN/yP8y3BV2OBGgw0RXhvM13W+G9g/Zj4aQ44T0alCiA34miELY5paznFdjzkk7wo7RIVRllESLf1xvbPRHDlGG5RYs1z1ueGBVGtkC826yTsN6nFdaxRd2Ntvgmbmpn3AiEoLxrAK4YbJ23X30W6iz5gen8j9O2N79vE+jvTcgBtP/38J0Lyy0OPQfH/VbrcEzrMdsPbyU87tORrhsUFg+NX1H0aVfTgLRM0h1kCYAtr2HpfIo4QCRHT8ar12aOKb69q8gNCdMf74S0krKIxiRAB8VgVMc4xCJQzjizOsosEj+sI6hMtrepjecf0guoGR5+qeYZ/WfREHLK9RRRzOXQntKdREt7dMJtYYlRZ50hT++tWoA6w4S0AyqCV7Q37e/YQtadkOkqwPo0F/TE2vuqmeRa14ZiZRh7ilMWv2cZ4AUGAWEtPkVs8lWlS5WvuRz0Jr3Qy5QnllPzSkzCotPA1+Ecap05SCnAgwG/RUt3UaUIU7+FDL/UHcmMX0AK4ec1MG98C6s31fmCgSTilEsKOj9zCMCGYfmp+MnX9YSqqrFdA8E2OLjscOkh17/FliGzx16QcAglTbU51DDhU8e0n/Kc6AHkylvmydfEm/PP23jGCO8hjaafWLcK6NRNxmK2EsHIut6sb0eUHwDBGYTdJPXvN961j41OIG/Piy0qj+329V9wkrjxGqklcHCVYHCyJX4/PLo3ieBPb4gf2xApFuaxGjsnufe1QY9jitSo8SPGBmcFfZOWdNgtEHK3HfrrSH30H4aMI9wRcbni36RjeRo9LjRgcPFeTljLo44323EjWk1UJ4P+h+zz5LLjXEFDKLPUyFe+AA/blisN1XRbwpvmAWBPVrZuZ4s=","salt":"v2:BFqA4FAHKX4qEywfJ:5b36c4cc-4e7a-497d-b4b0-a1df863c65c4","iterations":100000}',
|
||||
public_key:
|
||||
'{"alg":"RSA-OAEP-256","e":"AQAB","ext":true,"key_ops":["encrypt"],"kty":"RSA","n":"r0NSQ6ZOz2F-lXBRTwGBACu5dih-wQBDGf7gkvTASzqtC545BNRjLO9aGbhma-zBUc8JSJdZzpJUiKx0vHh9RG_RjE5znE9HgK66J1lSJNJQMjM0WZSRs1C95A-93k_MwrwJmIoWVr3a1xlOw1eO0Fwc0pr4LQsIDnfrkmxZQJTlzQXoetFlZv4HmJlM0yeMoeBBgMo-ZNKSJW_MtlwbV6SXpHLYE9I1bWO8ooIi84J8xGsjmHK3DAdmD_wNqISFK4Da3YoNSlPa_jdakAYy3nhlR0DEzz_bcxb_jXwpAH3G2DCMEYwRSap25ZytXVfN9mOkT7NYEMiVVdH4nrBEYQ"}',
|
||||
private_key:
|
||||
'{"alg":"RSA-OAEP-256","d":"BfaLapcFxwaKxDjeSCR4wb6H5xF5QCw36QvqX8JvqUX_NA4xFtn6RqgdVhMXep3DW641cA1xb8oIxRGt2YL1kb0gN97fdINtOMXSumebZODtYf5EZ-inJtIdDXcy2MjsSMZIqUwuQ5gRq90SxSpKE9eRrEfuuh5nxF5BWwNp65ZW4rCsEiInmnrFW4ERtyTHJ1catbJ_lj71lcVzC-1St6jeXcdinG2FtH0hJ4_ijzp6sAqm1xC9XMhF2g4tZeyvVg9dGBzyLyxq78zNAPJ93ungjc0ITJ27g3IaP7EUX7SxjHasU37j7KOIOGmTswkxIEoVQrlep6xU1RFkPphJNQ","dp":"sJhWI8YfVUr1N5vTr255xJ3Bo84320NWAl9MUhd87XoV3soGO0lmC1bYdrNvIU7wjJ3PxdpSrJ2HQDY5dR088RcmD4J1i3PFJUXVW0A_YkTIt2k8x5m1yF6npS0AgxgwauFxGcE7KgO2vtBn5SHMUOB-gmgUYhDRVB8NdQ3Z550","dq":"jXuFkyialQd264s-RW5adF637uYgh1pnutcQ1wI8HzVAcr3L36xrjQxDYMY6n5uNJJE0I3LyIe9Ez4j_83wiV3nsjFhSj-i7Doiy2k2zHRBqS9ajg933KVZVWD6fN7nr31jF_cdbkzzldkpgxXi3EeM2_0TN7kt1s1kvSeuEoME","e":"AQAB","ext":true,"key_ops":["decrypt"],"kty":"RSA","n":"r0NSQ6ZOz2F-lXBRTwGBACu5dih-wQBDGf7gkvTASzqtC545BNRjLO9aGbhma-zBUc8JSJdZzpJUiKx0vHh9RG_RjE5znE9HgK66J1lSJNJQMjM0WZSRs1C95A-93k_MwrwJmIoWVr3a1xlOw1eO0Fwc0pr4LQsIDnfrkmxZQJTlzQXoetFlZv4HmJlM0yeMoeBBgMo-ZNKSJW_MtlwbV6SXpHLYE9I1bWO8ooIi84J8xGsjmHK3DAdmD_wNqISFK4Da3YoNSlPa_jdakAYy3nhlR0DEzz_bcxb_jXwpAH3G2DCMEYwRSap25ZytXVfN9mOkT7NYEMiVVdH4nrBEYQ","p":"4yd_HHhnSAh-eGnF31n0ihyr2M7UBc3m2IDxbQBTohi3qUGD1x5YRlPtx1zpr0vZgoTkDUZ6DPbdXp6QBRku6SOKDrHA0jFiVdqmmVEPGKa1fLVtRIl_kDI-02-F4lvYlTfIQ4qchT64VSvwJnnxUlsuodfbohZi4zYaIMvzXZs","q":"xYTnIC-3Cy31zs0ZC5YTCTYp9KWD__jtcZjBmoqpc_dyhPa-Xzz2ty-6ytP_23mGi2vOB4rZjE9v2gh18l4AzhIrqR9ns4pxYZG4S2O3Si8oHavMJE4mx7VN6Lmgqs3DD45tA7zHbj8hnBg1It2aftWkvzxGx2yQe_r0JomXA7M","qi":"JF4tOm05ianqiNk2jtArg9O4kL8ZjiNgBA97Yu05b_f7k4MMKZqmRihCvzSMpk6w4pqkIdmnSSw19feNCXDv4QHFv4eSsTvBD8g_zmwIkMFyJzeC34J5bgafteZ1RpXsxN5CAAjyhJObEll3CY9B9zZTCLuZDr_616RWDvK1_w4"}',
|
||||
passphrase: 'prize fluid crystal small jaguar lunar bonus absent destroy settle carbon ignore',
|
||||
};
|
||||
|
||||
test('decrypt v2 private key', async () => {
|
||||
const keychain = new Keychain(pwdv2web.userId);
|
||||
const decrypted = await keychain.decryptKey(pwdv2web.persisted, pwdv2web.passphrase);
|
||||
expect(decrypted).toBe(pwdv2web.private_key);
|
||||
});
|
||||
|
||||
test('roundtrip v2 private key', async () => {
|
||||
const keychain = new Keychain(pwdv2web.userId);
|
||||
const decrypted = await keychain.decryptKey(pwdv2web.persisted, pwdv2web.passphrase);
|
||||
expect(decrypted).toBe(pwdv2web.private_key);
|
||||
const encrypted = await keychain.encryptKey(decrypted, pwdv2web.passphrase);
|
||||
expect(encrypted.iv).toHaveLength(base64Length(12));
|
||||
});
|
||||
});
|
||||
163
apps/meteor/client/lib/e2ee/keychain.ts
Normal file
163
apps/meteor/client/lib/e2ee/keychain.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { Base64 } from '@rocket.chat/base64';
|
||||
|
||||
import { Binary } from './binary';
|
||||
import type { ICodec } from './codec';
|
||||
import * as Pbkdf2 from './crypto/pbkdf2';
|
||||
import { randomUUID } from './crypto/shared';
|
||||
|
||||
/**
|
||||
* Version 1 format:
|
||||
* ```
|
||||
* json({ $binary: base64(iv[16] + ciphertext) })
|
||||
* ```
|
||||
*/
|
||||
interface IStoredKeyV1 {
|
||||
/**
|
||||
* Base64-encoded binary data
|
||||
* - first 16 bytes are the IV
|
||||
* - remaining bytes are the ciphertext
|
||||
*/
|
||||
$binary: string;
|
||||
}
|
||||
/**
|
||||
* Version 2 format:
|
||||
* ```typescript
|
||||
* json({ iv: base64(iv[12]), ciphertext: base64(data[...]), salt: string(), iterations: number() })
|
||||
* ```
|
||||
*/
|
||||
interface IStoredKeyV2 {
|
||||
iv: string;
|
||||
ciphertext: string;
|
||||
salt: string;
|
||||
iterations: number;
|
||||
}
|
||||
|
||||
type StoredKey = IStoredKeyV1 | IStoredKeyV2;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-redeclare
|
||||
const StoredKey: ICodec<string, StoredKey> = {
|
||||
decode: (data) => {
|
||||
const json: unknown = JSON.parse(data);
|
||||
|
||||
if (typeof json !== 'object' || json === null) {
|
||||
throw new TypeError('Invalid private key format');
|
||||
}
|
||||
|
||||
if ('$binary' in json && typeof json.$binary === 'string') {
|
||||
return { $binary: json.$binary } satisfies IStoredKeyV1;
|
||||
}
|
||||
|
||||
if (
|
||||
'iv' in json &&
|
||||
typeof json.iv === 'string' &&
|
||||
'ciphertext' in json &&
|
||||
typeof json.ciphertext === 'string' &&
|
||||
'salt' in json &&
|
||||
typeof json.salt === 'string' &&
|
||||
'iterations' in json &&
|
||||
typeof json.iterations === 'number'
|
||||
) {
|
||||
return { iv: json.iv, ciphertext: json.ciphertext, salt: json.salt, iterations: json.iterations } satisfies IStoredKeyV2;
|
||||
}
|
||||
|
||||
throw new TypeError('Invalid private key format');
|
||||
},
|
||||
encode: (data) => JSON.stringify(data),
|
||||
};
|
||||
|
||||
type EncryptedKeyContent = {
|
||||
iv: Uint8Array<ArrayBuffer>;
|
||||
ciphertext: Uint8Array<ArrayBuffer>;
|
||||
};
|
||||
|
||||
type EncryptedKeyOptions = {
|
||||
salt: string;
|
||||
iterations: number;
|
||||
};
|
||||
|
||||
type EncryptedKey = {
|
||||
content: EncryptedKeyContent;
|
||||
options: EncryptedKeyOptions;
|
||||
};
|
||||
|
||||
class EncryptedKeyCodec implements ICodec<string, EncryptedKey, IStoredKeyV2> {
|
||||
userId: string;
|
||||
|
||||
constructor(userId: string) {
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
encode(encryptedKey: EncryptedKey): IStoredKeyV2 {
|
||||
return {
|
||||
iv: Base64.encode(encryptedKey.content.iv),
|
||||
ciphertext: Base64.encode(encryptedKey.content.ciphertext),
|
||||
salt: encryptedKey.options.salt,
|
||||
iterations: encryptedKey.options.iterations,
|
||||
};
|
||||
}
|
||||
|
||||
decode(storedKey: string): EncryptedKey {
|
||||
const storedKeyObj = StoredKey.decode(storedKey);
|
||||
if ('$binary' in storedKeyObj) {
|
||||
// v1
|
||||
const binary = Base64.decode(storedKeyObj.$binary);
|
||||
|
||||
return {
|
||||
content: { iv: binary.slice(0, 16), ciphertext: binary.slice(16) },
|
||||
options: {
|
||||
salt: this.userId,
|
||||
iterations: 1000,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// v2
|
||||
const { iv, ciphertext, salt, iterations } = storedKeyObj;
|
||||
return {
|
||||
content: {
|
||||
iv: Base64.decode(iv),
|
||||
ciphertext: Base64.decode(ciphertext),
|
||||
},
|
||||
options: {
|
||||
salt,
|
||||
iterations,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class Keychain {
|
||||
private readonly userId: string;
|
||||
|
||||
private readonly codec: EncryptedKeyCodec;
|
||||
|
||||
constructor(userId: string) {
|
||||
this.userId = userId;
|
||||
this.codec = new EncryptedKeyCodec(userId);
|
||||
}
|
||||
|
||||
async decryptKey(privateKey: string, password: string): Promise<string> {
|
||||
const { content, options } = this.codec.decode(privateKey);
|
||||
const algorithm = content.iv.length === 16 ? 'AES-CBC' : 'AES-GCM';
|
||||
const baseKey = await Pbkdf2.importBaseKey(new Uint8Array(Binary.decode(password)));
|
||||
const derivedBits = await Pbkdf2.derive(baseKey, {
|
||||
salt: new Uint8Array(Binary.decode(options.salt)),
|
||||
iterations: options.iterations,
|
||||
});
|
||||
const key = await Pbkdf2.importKey(derivedBits, { name: algorithm, length: 256 });
|
||||
const decrypted = await Pbkdf2.decrypt(key, content);
|
||||
return Binary.encode(decrypted.buffer);
|
||||
}
|
||||
|
||||
async encryptKey(privateKey: string, password: string): Promise<IStoredKeyV2> {
|
||||
const salt = `v2:${this.userId}:${randomUUID()}`;
|
||||
const iterations = 100_000;
|
||||
const algorithm = 'AES-GCM';
|
||||
const baseKey = await Pbkdf2.importBaseKey(new Uint8Array(Binary.decode(password)));
|
||||
const derivedBits = await Pbkdf2.derive(baseKey, { salt: new Uint8Array(Binary.decode(salt)), iterations });
|
||||
const key = await Pbkdf2.importKey(derivedBits, { name: algorithm, length: 256 });
|
||||
const content = await Pbkdf2.encrypt(key, new Uint8Array(Binary.decode(privateKey)));
|
||||
|
||||
return this.codec.encode({ content, options: { salt, iterations } });
|
||||
}
|
||||
}
|
||||
100
apps/meteor/client/lib/e2ee/logger.ts
Normal file
100
apps/meteor/client/lib/e2ee/logger.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { getConfig } from '../utils/getConfig';
|
||||
|
||||
let debug: boolean | undefined = undefined;
|
||||
|
||||
const isDebugEnabled = (): boolean => {
|
||||
if (debug === undefined) {
|
||||
debug = getConfig('debug') === 'true' || getConfig('debug-e2e') === 'true';
|
||||
}
|
||||
|
||||
return debug;
|
||||
};
|
||||
|
||||
const noopSpan: ISpan = {
|
||||
set(_key: string, _value: unknown) {
|
||||
return this;
|
||||
},
|
||||
info(_message: string) {
|
||||
/**/
|
||||
},
|
||||
warn(_message: string) {
|
||||
/**/
|
||||
},
|
||||
error(_message: string, _error?: unknown) {
|
||||
/**/
|
||||
},
|
||||
};
|
||||
|
||||
class Logger {
|
||||
title: string;
|
||||
|
||||
constructor(title: string) {
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
span(label: string): ISpan {
|
||||
return isDebugEnabled() ? new Span(new WeakRef(this), label, console) : noopSpan;
|
||||
}
|
||||
}
|
||||
|
||||
interface ISpan {
|
||||
set(key: string, value: unknown): this;
|
||||
info(message: string): void;
|
||||
warn(message: string): void;
|
||||
error(message: string, error?: unknown): void;
|
||||
}
|
||||
|
||||
type LogLevel = 'info' | 'warn' | 'error';
|
||||
|
||||
const styles: Record<LogLevel, string> = {
|
||||
info: 'font-weight: bold;',
|
||||
warn: 'color: black; background-color: yellow; font-weight: bold;',
|
||||
error: 'color: white; background-color: red; font-weight: bold;',
|
||||
};
|
||||
|
||||
class Span {
|
||||
private logger: WeakRef<Logger>;
|
||||
|
||||
private label: string;
|
||||
|
||||
private attributes = new Map<string, unknown>();
|
||||
|
||||
private console: Console;
|
||||
|
||||
constructor(logger: WeakRef<Logger>, label: string, console: Console) {
|
||||
this.logger = logger;
|
||||
this.label = label;
|
||||
this.console = console;
|
||||
}
|
||||
|
||||
private log(level: LogLevel, message: string) {
|
||||
this.console.groupCollapsed(`%c[${this.logger.deref()?.title}:${this.label}]%c ${message}`, styles[level], 'font-weight: normal;');
|
||||
this.console.dir(Object.fromEntries(this.attributes.entries()), {});
|
||||
this.console.trace();
|
||||
this.console.groupEnd();
|
||||
}
|
||||
|
||||
set(key: string, value: unknown) {
|
||||
this.attributes.set(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
info(message: string) {
|
||||
this.log('info', message);
|
||||
}
|
||||
|
||||
warn(message: string) {
|
||||
this.log('warn', message);
|
||||
}
|
||||
|
||||
error(message: string, error?: unknown) {
|
||||
if (error) {
|
||||
this.set('error', error);
|
||||
}
|
||||
this.log('error', message);
|
||||
}
|
||||
}
|
||||
|
||||
export const createLogger = (title: string) => {
|
||||
return new Logger(title);
|
||||
};
|
||||
39
apps/meteor/client/lib/e2ee/prefixed.spec.ts
Normal file
39
apps/meteor/client/lib/e2ee/prefixed.spec.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Base64 } from '@rocket.chat/base64';
|
||||
|
||||
import { PrefixedBase64 } from './prefixed';
|
||||
|
||||
const prefix = '32c9e7917b78';
|
||||
const base64 =
|
||||
'ibtLAKG9zcQ/NTp+86nVelUjewPbPNW+EC+eagVPVVlbxvWNXkgltrBQB4gDao1Fp6fHUibQB3dirJ4rzy7CViww0o4QjAwPPQMIxZ9DLJhjKnu6bkkOp6Z0/a9g/8Wf/cvP9/bp7tUt7Et4XMmJwIe5iyJZ35lsyduLc8V+YyK8sJiGf4BRagJoBr8xEBgqBWqg6Vwn3qtbbiTs65PqErbaUmSM3Hn6tfkcS6ukLG/DbptW1B9U66IX3fQesj50zWZiJyvxOoxDeHRH9UEStyv9SP8nrFjEKM3TDiakBeDxja6LoN8l3CjP9K/5eg25YqANZAQjlwaCaeTTHndTgQ==';
|
||||
const prefixed = `${prefix}${base64}`;
|
||||
|
||||
describe('PrefixedBase64', () => {
|
||||
it('should roundtrip', () => {
|
||||
const [kid, decodedKey] = PrefixedBase64.decode(prefixed);
|
||||
expect(kid).toBe(prefix);
|
||||
const reencoded = PrefixedBase64.encode([kid, decodedKey]);
|
||||
expect(reencoded).toBe(prefixed);
|
||||
});
|
||||
|
||||
it('should throw on invalid decode input length', () => {
|
||||
expect(() => PrefixedBase64.decode('too-short')).toThrow(RangeError);
|
||||
});
|
||||
|
||||
it('should throw on invalid decoded data length', () => {
|
||||
const invalidPrefixed = `32c9e7917b78${'A'.repeat(343)}`; // One character short
|
||||
expect(() => PrefixedBase64.decode(invalidPrefixed)).toThrow(RangeError);
|
||||
});
|
||||
|
||||
it('should throw on invalid encode input data length', () => {
|
||||
const invalidData = new Uint8Array(255); // One byte short
|
||||
expect(() => PrefixedBase64.encode([prefix, invalidData])).toThrow(RangeError);
|
||||
});
|
||||
|
||||
it('should throw on invalid encoded Base64 length', () => {
|
||||
// This is a bit contrived since our encode implementation always produces valid length,
|
||||
// but we include it for completeness.
|
||||
const invalidData = new Uint8Array(256);
|
||||
jest.spyOn(Base64, 'encode').mockReturnValue('A'.repeat(343)); // One character short
|
||||
expect(() => PrefixedBase64.encode([prefix, invalidData])).toThrow(RangeError);
|
||||
});
|
||||
});
|
||||
55
apps/meteor/client/lib/e2ee/prefixed.ts
Normal file
55
apps/meteor/client/lib/e2ee/prefixed.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Base64 } from '@rocket.chat/base64';
|
||||
|
||||
import type { ICodec } from './codec';
|
||||
|
||||
// A 256-byte array always encodes to 344 characters in Base64.
|
||||
const DECODED_LENGTH = 256;
|
||||
// ((4 * 256 / 3) + 3) & ~3 = 344
|
||||
const ENCODED_LENGTH = 344;
|
||||
|
||||
/**
|
||||
* A codec for strings formatted as "prefix + base64(data)", where:
|
||||
* - `prefix` is an arbitrary-length string
|
||||
* - `data` is a 256-byte Uint8Array encoded in Base64
|
||||
* - the total length of the Base64-encoded data is always 344 characters
|
||||
* This is used for encoding/decoding E2EE keys with a key ID prefix.
|
||||
*/
|
||||
export const PrefixedBase64: ICodec<string, [prefix: string, data: Uint8Array<ArrayBuffer>]> = {
|
||||
decode: (input) => {
|
||||
// 1. Validate the input string length
|
||||
if (input.length < ENCODED_LENGTH) {
|
||||
throw new RangeError('Invalid input length.');
|
||||
}
|
||||
|
||||
// 2. Split the string into its two parts
|
||||
const prefix = input.slice(0, -ENCODED_LENGTH);
|
||||
const base64Data = input.slice(-ENCODED_LENGTH);
|
||||
|
||||
// 3. Decode the Base64 string. atob() decodes to a "binary string".
|
||||
const bytes = Base64.decode(base64Data);
|
||||
|
||||
if (bytes.length !== DECODED_LENGTH) {
|
||||
// This is a sanity check in case the Base64 string was valid but didn't decode to 256 bytes.
|
||||
throw new RangeError('Decoded data length is too short.');
|
||||
}
|
||||
|
||||
return [prefix, bytes];
|
||||
},
|
||||
encode: ([prefix, data]) => {
|
||||
// 1. Validate the input data length
|
||||
if (data.length !== DECODED_LENGTH) {
|
||||
throw new RangeError(`Input data length is ${data.length}, but expected ${DECODED_LENGTH} bytes.`);
|
||||
}
|
||||
|
||||
// 2. Convert the byte array (Uint8Array) into a Base64 string
|
||||
const base64 = Base64.encode(data);
|
||||
|
||||
if (base64.length !== ENCODED_LENGTH) {
|
||||
// This is a sanity check in case something went wrong during encoding.
|
||||
throw new RangeError(`Encoded Base64 length is ${base64.length}, but expected ${ENCODED_LENGTH} characters.`);
|
||||
}
|
||||
|
||||
// 3. Concatenate the prefix and the Base64 string
|
||||
return prefix + base64;
|
||||
},
|
||||
};
|
||||
81
apps/meteor/client/lib/e2ee/rocketchat.e2e.room.spec.ts
Normal file
81
apps/meteor/client/lib/e2ee/rocketchat.e2e.room.spec.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import * as Aes from './crypto/aes';
|
||||
import { E2ERoom } from './rocketchat.e2e.room';
|
||||
import { sdk } from '../SDKClient';
|
||||
|
||||
jest.mock('./crypto/aes');
|
||||
jest.mock('../../../client/lib/SDKClient', () => ({
|
||||
sdk: { rest: { post: jest.fn() } },
|
||||
}));
|
||||
jest.mock('../../../app/utils/rocketchat.info', () => ({
|
||||
Info: { version: '8.7.0' },
|
||||
}));
|
||||
|
||||
class FakeResponse {
|
||||
constructor(
|
||||
private readonly body: unknown,
|
||||
public readonly status = 400,
|
||||
) {}
|
||||
|
||||
clone() {
|
||||
return this;
|
||||
}
|
||||
|
||||
async json() {
|
||||
return this.body;
|
||||
}
|
||||
}
|
||||
(global as any).Response = FakeResponse;
|
||||
|
||||
const makeRoom = () => new E2ERoom('user-1', { _id: 'room-1', t: 'p', encrypted: true } as any);
|
||||
|
||||
describe('E2ERoom.createGroupKey', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(Aes.generate as jest.Mock).mockResolvedValue('group-key');
|
||||
(Aes.exportJwk as jest.Mock).mockResolvedValue({ k: 'jwk' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should discard the local key and not distribute it when it loses the race', async () => {
|
||||
(sdk.rest.post as jest.Mock).mockRejectedValueOnce(
|
||||
new (global as any).Response({ success: false, errorType: 'error-room-e2e-key-already-exists' }),
|
||||
);
|
||||
|
||||
const e2eRoom = makeRoom();
|
||||
const result = await e2eRoom.createGroupKey();
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(e2eRoom.groupSessionKey).toBeNull();
|
||||
expect(e2eRoom.keyID).toBeUndefined();
|
||||
expect(sdk.rest.post).toHaveBeenCalledTimes(1); // only setRoomKeyID was called
|
||||
expect(sdk.rest.post).toHaveBeenCalledWith('/v1/e2e.setRoomKeyID', { rid: e2eRoom.roomId, keyID: expect.any(String) });
|
||||
});
|
||||
|
||||
it('should return true and distribute the key when it wins the race', async () => {
|
||||
(sdk.rest.post as jest.Mock).mockResolvedValue({});
|
||||
jest.spyOn(E2ERoom.prototype as any, 'encryptGroupKeyForParticipant').mockResolvedValue('mykey');
|
||||
const encryptForOthersSpy = jest.spyOn(E2ERoom.prototype as any, 'encryptKeyForOtherParticipants').mockResolvedValue(undefined);
|
||||
|
||||
const e2eRoom = makeRoom();
|
||||
const result = await e2eRoom.createGroupKey();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(e2eRoom.groupSessionKey).not.toBeNull();
|
||||
|
||||
expect(sdk.rest.post).toHaveBeenCalledWith('/v1/e2e.setRoomKeyID', {
|
||||
rid: e2eRoom.roomId,
|
||||
keyID: expect.any(String),
|
||||
});
|
||||
expect(sdk.rest.post).toHaveBeenCalledWith('/v1/e2e.updateGroupKey', {
|
||||
rid: e2eRoom.roomId,
|
||||
uid: e2eRoom.userId,
|
||||
key: 'mykey',
|
||||
});
|
||||
|
||||
expect(encryptForOthersSpy).toHaveBeenCalledTimes(1);
|
||||
expect(sdk.rest.post).toHaveBeenCalledTimes(2); // both setRoomKeyID and updateGroupKey are called
|
||||
});
|
||||
});
|
||||
846
apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts
Normal file
846
apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts
Normal file
@@ -0,0 +1,846 @@
|
||||
import { Base64 } from '@rocket.chat/base64';
|
||||
import type {
|
||||
IE2EEMessage,
|
||||
IMessage,
|
||||
IRoom,
|
||||
ISubscription,
|
||||
IUser,
|
||||
AtLeast,
|
||||
EncryptedMessageContent,
|
||||
EncryptedContent,
|
||||
} from '@rocket.chat/core-typings';
|
||||
import { isEncryptedMessageContent, isFileAttachment, isRemovedFileAttachment } from '@rocket.chat/core-typings';
|
||||
import { Emitter } from '@rocket.chat/emitter';
|
||||
import type { Optional } from '@tanstack/react-query';
|
||||
import EJSON from 'ejson';
|
||||
|
||||
import type { E2ERoomState } from './E2ERoomState';
|
||||
import { Binary } from './binary';
|
||||
import { decodeEncryptedContent } from './content';
|
||||
import * as Aes from './crypto/aes';
|
||||
import * as Rsa from './crypto/rsa';
|
||||
import { encryptAESCTR, generateAESCTRKey, sha256HashFromArrayBuffer, createSha256HashFromText } from './helper';
|
||||
import { createLogger } from './logger';
|
||||
import { PrefixedBase64 } from './prefixed';
|
||||
import { e2e } from './rocketchat.e2e';
|
||||
import { t } from '../../../app/utils/lib/i18n';
|
||||
import { RoomSettingsEnum } from '../../../definition/IRoomTypeConfig';
|
||||
import { Messages, Rooms, Subscriptions } from '../../stores';
|
||||
import { sdk } from '../SDKClient';
|
||||
import { roomCoordinator } from '../rooms/roomCoordinator';
|
||||
|
||||
const log = createLogger('E2E:Room');
|
||||
|
||||
const KEY_ID = Symbol('keyID');
|
||||
const PAUSED = Symbol('PAUSED');
|
||||
|
||||
const isRoomKeyAlreadyExistsError = async (error: Response): Promise<boolean> => {
|
||||
try {
|
||||
const body = await error.clone().json();
|
||||
return body?.errorType === 'error-room-e2e-key-already-exists';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
type Mutations = { [k in E2ERoomState]?: E2ERoomState[] };
|
||||
|
||||
const permitedMutations: Mutations = {
|
||||
NOT_STARTED: ['ESTABLISHING', 'DISABLED', 'KEYS_RECEIVED'],
|
||||
READY: ['DISABLED', 'CREATING_KEYS', 'WAITING_KEYS'],
|
||||
ERROR: ['KEYS_RECEIVED', 'NOT_STARTED'],
|
||||
WAITING_KEYS: ['KEYS_RECEIVED', 'ERROR', 'DISABLED'],
|
||||
ESTABLISHING: ['READY', 'KEYS_RECEIVED', 'ERROR', 'DISABLED', 'WAITING_KEYS', 'CREATING_KEYS'],
|
||||
};
|
||||
|
||||
const filterMutation = (currentState: E2ERoomState | undefined, nextState: E2ERoomState): E2ERoomState | false => {
|
||||
// When state is undefined, allow it to be moved
|
||||
if (!currentState) {
|
||||
return nextState;
|
||||
}
|
||||
|
||||
if (currentState === nextState) {
|
||||
return nextState === 'ERROR' ? 'ERROR' : false;
|
||||
}
|
||||
|
||||
if (!(currentState in permitedMutations)) {
|
||||
return nextState;
|
||||
}
|
||||
|
||||
if (permitedMutations?.[currentState]?.includes(nextState)) {
|
||||
return nextState;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export class E2ERoom extends Emitter {
|
||||
state: E2ERoomState | undefined = undefined;
|
||||
|
||||
[PAUSED]: boolean | undefined = undefined;
|
||||
|
||||
[KEY_ID]: string;
|
||||
|
||||
userId: string;
|
||||
|
||||
roomId: string;
|
||||
|
||||
typeOfRoom: string;
|
||||
|
||||
roomKeyId: string | undefined;
|
||||
|
||||
groupSessionKey: Aes.Key | null = null;
|
||||
|
||||
oldKeys: { E2EKey: Aes.Key | null; ts: Date; e2eKeyId: string }[] | undefined;
|
||||
|
||||
sessionKeyExportedString: string | undefined;
|
||||
|
||||
sessionKeyExported: Aes.Jwk | undefined;
|
||||
|
||||
constructor(userId: string, room: IRoom) {
|
||||
super();
|
||||
|
||||
this.userId = userId;
|
||||
this.roomId = room._id;
|
||||
this.typeOfRoom = room.t;
|
||||
this.roomKeyId = room.e2eKeyId;
|
||||
|
||||
this.once('READY', async () => {
|
||||
await this.decryptOldRoomKeys();
|
||||
return this.decryptPendingMessages();
|
||||
});
|
||||
this.once('READY', () => this.decryptSubscription());
|
||||
this.on('STATE_CHANGED', () => this.handshake());
|
||||
|
||||
this.setState('NOT_STARTED');
|
||||
}
|
||||
|
||||
hasSessionKey() {
|
||||
return !!this.groupSessionKey;
|
||||
}
|
||||
|
||||
getState() {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
setState(requestedState: E2ERoomState) {
|
||||
const span = log.span('setState');
|
||||
const currentState = this.state;
|
||||
const nextState = filterMutation(currentState, requestedState);
|
||||
|
||||
if (!nextState) {
|
||||
span.error(`${currentState} -> ${requestedState}`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.state = nextState;
|
||||
span.info(`${currentState} -> ${nextState}`);
|
||||
this.emit('STATE_CHANGED', currentState);
|
||||
this.emit(nextState, this);
|
||||
}
|
||||
|
||||
isReady() {
|
||||
return this.state === 'READY';
|
||||
}
|
||||
|
||||
isDisabled() {
|
||||
return this.state === 'DISABLED';
|
||||
}
|
||||
|
||||
enable() {
|
||||
if (this.state === 'READY') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState('READY');
|
||||
}
|
||||
|
||||
disable() {
|
||||
this.setState('DISABLED');
|
||||
}
|
||||
|
||||
pause() {
|
||||
this[PAUSED] = true;
|
||||
this.emit('PAUSED', true);
|
||||
}
|
||||
|
||||
resume() {
|
||||
this[PAUSED] = false;
|
||||
this.emit('PAUSED', false);
|
||||
}
|
||||
|
||||
keyReceived() {
|
||||
this.setState('KEYS_RECEIVED');
|
||||
}
|
||||
|
||||
async shouldConvertSentMessages(message: { msg?: string }) {
|
||||
if (!this.isReady() || this[PAUSED]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this[PAUSED] === undefined) {
|
||||
return new Promise((resolve) => {
|
||||
this.once('PAUSED', resolve);
|
||||
});
|
||||
}
|
||||
|
||||
if (message.msg?.[0] === '/') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
shouldConvertReceivedMessages() {
|
||||
return this.isReady();
|
||||
}
|
||||
|
||||
isWaitingKeys() {
|
||||
return this.state === 'WAITING_KEYS';
|
||||
}
|
||||
|
||||
get keyID() {
|
||||
return this[KEY_ID];
|
||||
}
|
||||
|
||||
set keyID(keyID) {
|
||||
this[KEY_ID] = keyID;
|
||||
}
|
||||
|
||||
async decryptSubscription() {
|
||||
const span = log.span('decryptSubscription');
|
||||
const subscription = Subscriptions.state.find((record) => record.rid === this.roomId);
|
||||
|
||||
if (!subscription) {
|
||||
span.warn('no subscription found');
|
||||
return;
|
||||
}
|
||||
|
||||
const { lastMessage } = subscription;
|
||||
|
||||
if (lastMessage === undefined) {
|
||||
span.warn('no lastMessage found');
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastMessage.t !== 'e2e') {
|
||||
span.warn('nothing to do');
|
||||
return;
|
||||
}
|
||||
|
||||
const message = await this.decryptMessage(lastMessage);
|
||||
|
||||
if (message.msg !== subscription.lastMessage?.msg) {
|
||||
span.info('decryptSubscriptions updating lastMessage');
|
||||
Subscriptions.state.store({
|
||||
...subscription,
|
||||
lastMessage: message,
|
||||
});
|
||||
}
|
||||
|
||||
span.info('decryptSubscriptions Done');
|
||||
}
|
||||
|
||||
async decryptOldRoomKeys() {
|
||||
const span = log.span('decryptOldRoomKeys');
|
||||
const sub = Subscriptions.state.find((record) => record.rid === this.roomId);
|
||||
|
||||
if (!sub?.oldRoomKeys || sub?.oldRoomKeys.length === 0) {
|
||||
span.info('Nothing to do');
|
||||
return;
|
||||
}
|
||||
|
||||
const keys = [];
|
||||
for await (const key of sub.oldRoomKeys) {
|
||||
try {
|
||||
const k = await this.decryptSessionKey(key.E2EKey);
|
||||
keys.push({
|
||||
...key,
|
||||
E2EKey: k,
|
||||
});
|
||||
} catch (error) {
|
||||
span.error(
|
||||
`Cannot decrypt old room key with id ${key.e2eKeyId}. This is likely because user private key changed or is missing. Skipping`,
|
||||
error,
|
||||
);
|
||||
keys.push({ ...key, E2EKey: null });
|
||||
}
|
||||
}
|
||||
|
||||
this.oldKeys = keys;
|
||||
}
|
||||
|
||||
async exportOldRoomKeys(oldKeys: ISubscription['oldRoomKeys']) {
|
||||
const span = log.span('exportOldRoomKeys').set('oldKeys', oldKeys);
|
||||
if (!oldKeys || oldKeys.length === 0) {
|
||||
span.info('Nothing to do');
|
||||
return;
|
||||
}
|
||||
|
||||
const keys = [];
|
||||
for await (const key of oldKeys) {
|
||||
try {
|
||||
if (!key.E2EKey) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const k = await this.exportSessionKey(key.E2EKey);
|
||||
keys.push({
|
||||
...key,
|
||||
E2EKey: k,
|
||||
});
|
||||
} catch (e) {
|
||||
span.set('error', e);
|
||||
span.error(
|
||||
`Cannot decrypt old room key with id ${key.e2eKeyId}. This is likely because user private key changed or is missing. Skipping.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
span.info(`Done: ${keys.length} keys exported`);
|
||||
return keys;
|
||||
}
|
||||
|
||||
async decryptPendingMessages() {
|
||||
await Messages.state.updateAsync(
|
||||
(record) => record.rid === this.roomId && record.t === 'e2e' && record.e2e === 'pending',
|
||||
(record) => this.decryptMessage(record as IE2EEMessage),
|
||||
);
|
||||
}
|
||||
|
||||
// Initiates E2E Encryption
|
||||
async handshake() {
|
||||
const span = log.span('handshake');
|
||||
if (!e2e.isReady()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.state !== 'KEYS_RECEIVED' && this.state !== 'NOT_STARTED') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState('ESTABLISHING');
|
||||
|
||||
try {
|
||||
const groupKey = Subscriptions.state.find((record) => record.rid === this.roomId)?.E2EKey;
|
||||
if (groupKey) {
|
||||
await this.importGroupKey(groupKey);
|
||||
this.setState('READY');
|
||||
span.info('Group key imported');
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
this.setState('ERROR');
|
||||
span.error('Error fetching group key', error);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const room = Rooms.state.get(this.roomId);
|
||||
span.set('room', room);
|
||||
if (!room?.e2eKeyId) {
|
||||
this.setState('CREATING_KEYS');
|
||||
const created = await this.createGroupKey();
|
||||
if (!created) {
|
||||
// another member won the race
|
||||
this.setState('WAITING_KEYS');
|
||||
return;
|
||||
}
|
||||
this.setState('READY');
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState('WAITING_KEYS');
|
||||
span.info('Requesting room key');
|
||||
sdk.publish('notify-room-users', [`${this.roomId}/e2ekeyRequest`, this.roomId, room.e2eKeyId]);
|
||||
} catch (error) {
|
||||
span.set('error', error);
|
||||
span.error('Error during handshake');
|
||||
this.setState('ERROR');
|
||||
}
|
||||
}
|
||||
|
||||
isSupportedRoomType(type: string) {
|
||||
return roomCoordinator.getRoomDirectives(type).allowRoomSettingChange({}, RoomSettingsEnum.E2E);
|
||||
}
|
||||
|
||||
async decryptSessionKey(key: string) {
|
||||
return Aes.importKey(JSON.parse(await this.exportSessionKey(key)));
|
||||
}
|
||||
|
||||
async exportSessionKey(key: string) {
|
||||
const span = log.span('exportSessionKey');
|
||||
const [prefix, decodedKey] = PrefixedBase64.decode(key);
|
||||
|
||||
span.set('prefix', prefix);
|
||||
|
||||
if (!e2e.privateKey) {
|
||||
span.error('Private key not found');
|
||||
throw new Error('Private key not found');
|
||||
}
|
||||
|
||||
const decryptedKey = await Rsa.decrypt(e2e.privateKey, decodedKey);
|
||||
span.info('Key decrypted');
|
||||
return Binary.encode(decryptedKey.buffer);
|
||||
}
|
||||
|
||||
async importGroupKey(groupKey: string) {
|
||||
const span = log.span('importGroupKey');
|
||||
const [kid, decodedKey] = PrefixedBase64.decode(groupKey);
|
||||
|
||||
span.set('kid', kid);
|
||||
|
||||
if (this.keyID === kid && this.groupSessionKey) {
|
||||
span.info('Key already imported');
|
||||
return true;
|
||||
}
|
||||
|
||||
// Decrypt obtained encrypted session key
|
||||
try {
|
||||
if (!e2e.privateKey) {
|
||||
throw new Error('Private key not found');
|
||||
}
|
||||
const decryptedKey = await Rsa.decrypt(e2e.privateKey, decodedKey);
|
||||
this.sessionKeyExportedString = Binary.encode(decryptedKey.buffer);
|
||||
} catch (error) {
|
||||
span.set('error', error).error('Error decrypting group key');
|
||||
return false;
|
||||
}
|
||||
|
||||
// When a new e2e room is created, it will be initialized without an e2e key id
|
||||
// This will prevent new rooms from storing `undefined` as the keyid
|
||||
if (!this.keyID) {
|
||||
this.keyID = this.roomKeyId || kid || crypto.randomUUID();
|
||||
}
|
||||
|
||||
// Import session key for use.
|
||||
try {
|
||||
const key = await Aes.importKey(JSON.parse(this.sessionKeyExportedString));
|
||||
// Key has been obtained. E2E is now in session.
|
||||
this.groupSessionKey = key;
|
||||
span.info('Group key imported');
|
||||
} catch (error) {
|
||||
span.set('error', error).error('Error importing group key');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async createNewGroupKey() {
|
||||
this.groupSessionKey = await Aes.generate();
|
||||
const sessionKeyExported = await Aes.exportJwk(this.groupSessionKey);
|
||||
this.sessionKeyExportedString = JSON.stringify(sessionKeyExported);
|
||||
this.keyID = crypto.randomUUID();
|
||||
}
|
||||
|
||||
private discardGroupKey() {
|
||||
this.groupSessionKey = null;
|
||||
this.sessionKeyExportedString = undefined;
|
||||
this.keyID = undefined as unknown as string;
|
||||
}
|
||||
|
||||
async createGroupKey(): Promise<boolean> {
|
||||
const span = log.span('createGroupKey');
|
||||
await this.createNewGroupKey();
|
||||
|
||||
try {
|
||||
await sdk.rest.post('/v1/e2e.setRoomKeyID', { rid: this.roomId, keyID: this.keyID });
|
||||
} catch (error) {
|
||||
if (error instanceof Response && (await isRoomKeyAlreadyExistsError(error))) {
|
||||
span.info('Room key already established by another member; discarding local key');
|
||||
this.discardGroupKey();
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const myKey = await this.encryptGroupKeyForParticipant(e2e.publicKey!);
|
||||
if (myKey) {
|
||||
await sdk.rest.post('/v1/e2e.updateGroupKey', {
|
||||
rid: this.roomId,
|
||||
uid: this.userId,
|
||||
key: myKey,
|
||||
});
|
||||
await this.encryptKeyForOtherParticipants();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async resetRoomKey() {
|
||||
const span = log.span('resetRoomKey');
|
||||
|
||||
if (!e2e.publicKey) {
|
||||
span.error('No public key found');
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState('CREATING_KEYS');
|
||||
try {
|
||||
await this.createNewGroupKey();
|
||||
|
||||
const e2eNewKeys = { e2eKeyId: this.keyID, e2eKey: await this.encryptGroupKeyForParticipant(e2e.publicKey) };
|
||||
|
||||
this.setState('READY');
|
||||
span.set('kid', this.keyID).info('Room key reset successfully');
|
||||
|
||||
return e2eNewKeys;
|
||||
} catch (error) {
|
||||
span.error('Error resetting room key', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
onRoomKeyReset(keyID: string) {
|
||||
const span = log.span('onRoomKeyReset').set('key_id', keyID);
|
||||
|
||||
if (this.keyID === keyID) {
|
||||
span.warn('Key ID matches current key, nothing to do');
|
||||
return;
|
||||
}
|
||||
|
||||
span.set('new_key_id', keyID).info('Room key has been reset');
|
||||
this.setState('WAITING_KEYS');
|
||||
this.keyID = keyID;
|
||||
this.groupSessionKey = null;
|
||||
this.sessionKeyExportedString = undefined;
|
||||
this.sessionKeyExported = undefined;
|
||||
this.oldKeys = undefined;
|
||||
}
|
||||
|
||||
async encryptKeyForOtherParticipants() {
|
||||
const span = log.span('encryptKeyForOtherParticipants');
|
||||
// Encrypt generated session key for every user in room and publish to subscription model.
|
||||
try {
|
||||
const mySub = Subscriptions.state.find((record) => record.rid === this.roomId);
|
||||
const decryptedOldGroupKeys = await this.exportOldRoomKeys(mySub?.oldRoomKeys);
|
||||
const users = (await sdk.rest.get('/v1/e2e.getUsersOfRoomWithoutKey', { rid: this.roomId })).users.filter(
|
||||
(user) => user?.e2e?.public_key,
|
||||
);
|
||||
|
||||
if (!users.length) {
|
||||
span.info('No users to encrypt the key for');
|
||||
return;
|
||||
}
|
||||
|
||||
const usersSuggestedGroupKeys: Record<
|
||||
string,
|
||||
{
|
||||
_id: IUser['_id'];
|
||||
key: string;
|
||||
oldKeys: ISubscription['suggestedOldRoomKeys'];
|
||||
}[]
|
||||
> = { [this.roomId]: [] };
|
||||
for await (const user of users) {
|
||||
const encryptedGroupKey = await this.encryptGroupKeyForParticipant(user.e2e!.public_key);
|
||||
if (!encryptedGroupKey) {
|
||||
span.warn(`Could not encrypt group key for user ${user._id}`);
|
||||
return;
|
||||
}
|
||||
if (decryptedOldGroupKeys) {
|
||||
const oldKeys = await this.encryptOldKeysForParticipant(user.e2e!.public_key, decryptedOldGroupKeys);
|
||||
if (oldKeys) {
|
||||
usersSuggestedGroupKeys[this.roomId].push({ _id: user._id, key: encryptedGroupKey, oldKeys });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
usersSuggestedGroupKeys[this.roomId].push({ _id: user._id, key: encryptedGroupKey, oldKeys: undefined });
|
||||
}
|
||||
|
||||
await sdk.rest.post('/v1/e2e.provideUsersSuggestedGroupKeys', { usersSuggestedGroupKeys });
|
||||
} catch (error) {
|
||||
return span.set('error', error).error('Error getting room users');
|
||||
}
|
||||
}
|
||||
|
||||
async encryptOldKeysForParticipant(publicKey: string, oldRoomKeys: { E2EKey: string; e2eKeyId: string; ts: Date }[]) {
|
||||
const span = log.span('encryptOldKeysForParticipant');
|
||||
if (!oldRoomKeys || oldRoomKeys.length === 0) {
|
||||
span.info('Nothing to do');
|
||||
return;
|
||||
}
|
||||
|
||||
let userKey;
|
||||
|
||||
try {
|
||||
userKey = await Rsa.importPublicKey(JSON.parse(publicKey));
|
||||
} catch (error) {
|
||||
return span.set('error', error).error('Error importing user key');
|
||||
}
|
||||
|
||||
try {
|
||||
const keys = [];
|
||||
for await (const oldRoomKey of oldRoomKeys) {
|
||||
if (!oldRoomKey.E2EKey) {
|
||||
continue;
|
||||
}
|
||||
const encryptedKey = await Rsa.encrypt(userKey, Binary.decode(oldRoomKey.E2EKey));
|
||||
const encryptedKeyToString = PrefixedBase64.encode([oldRoomKey.e2eKeyId, encryptedKey]);
|
||||
|
||||
keys.push({ ...oldRoomKey, E2EKey: encryptedKeyToString });
|
||||
}
|
||||
return keys;
|
||||
} catch (error) {
|
||||
return span.set('error', error).error('failed to encrypt old keys for participant');
|
||||
}
|
||||
}
|
||||
|
||||
async encryptGroupKeyForParticipant(publicKey: string) {
|
||||
const span = log.span('encryptGroupKeyForParticipant');
|
||||
let userKey;
|
||||
try {
|
||||
userKey = await Rsa.importPublicKey(JSON.parse(publicKey));
|
||||
} catch (error) {
|
||||
return span.set('error', error).error('Error importing user key');
|
||||
}
|
||||
|
||||
// Encrypt session key for this user with his/her public key
|
||||
try {
|
||||
const encryptedUserKey = await Rsa.encrypt(userKey, Binary.decode(this.sessionKeyExportedString!));
|
||||
const encryptedUserKeyToString = PrefixedBase64.encode([this.keyID, encryptedUserKey]);
|
||||
span.info('Group key encrypted for participant');
|
||||
return encryptedUserKeyToString;
|
||||
} catch (error) {
|
||||
return span.error('Error encrypting user key', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Encrypts files before upload. I/O is in arraybuffers.
|
||||
async encryptFile(file: File) {
|
||||
const span = log.span('encryptFile');
|
||||
|
||||
const fileArrayBuffer = await file.arrayBuffer();
|
||||
|
||||
const hash = await sha256HashFromArrayBuffer(fileArrayBuffer);
|
||||
|
||||
const vector = crypto.getRandomValues(new Uint8Array(16));
|
||||
const key = await generateAESCTRKey();
|
||||
let result;
|
||||
try {
|
||||
result = await encryptAESCTR(vector, key, fileArrayBuffer);
|
||||
} catch (error) {
|
||||
return span.set('error', error).error('Error encrypting group key');
|
||||
}
|
||||
|
||||
const exportedKey = await window.crypto.subtle.exportKey('jwk', key);
|
||||
|
||||
const fileName = await createSha256HashFromText(file.name);
|
||||
|
||||
const encryptedFile = new File([result], fileName);
|
||||
|
||||
return {
|
||||
file: encryptedFile,
|
||||
key: exportedKey,
|
||||
iv: Base64.encode(vector),
|
||||
type: file.type,
|
||||
hash,
|
||||
};
|
||||
}
|
||||
|
||||
// Encrypts messages
|
||||
async encryptText(data: Uint8Array<ArrayBuffer>) {
|
||||
const span = log.span('encryptText');
|
||||
|
||||
try {
|
||||
if (!this.groupSessionKey) {
|
||||
throw new Error('No group session key found.');
|
||||
}
|
||||
|
||||
const { iv, ciphertext } = await Aes.encrypt(this.groupSessionKey, data);
|
||||
const encryptedData = {
|
||||
kid: this.keyID,
|
||||
iv: Base64.encode(iv),
|
||||
ciphertext: Base64.encode(ciphertext),
|
||||
};
|
||||
span.info('message encrypted');
|
||||
return encryptedData;
|
||||
} catch (error) {
|
||||
span.error('Error encrypting message', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function for encryption of content
|
||||
async encryptMessageContent(
|
||||
contentToBeEncrypted: Pick<IMessage, 'attachments' | 'files' | 'file'> & Optional<Pick<IMessage, 'msg'>, 'msg'>,
|
||||
) {
|
||||
const data = new TextEncoder().encode(EJSON.stringify(contentToBeEncrypted));
|
||||
|
||||
return {
|
||||
algorithm: 'rc.v2.aes-sha2',
|
||||
...(await this.encryptText(data)),
|
||||
} as const;
|
||||
}
|
||||
|
||||
// Helper function for encryption of content
|
||||
async encryptMessage(message: AtLeast<IMessage, '_id' | 'rid' | 'msg'>): Promise<IE2EEMessage> {
|
||||
const { msg, attachments, ...rest } = message;
|
||||
|
||||
const content = await this.encryptMessageContent({ msg, attachments });
|
||||
|
||||
// E2EMessages remove the `msg` property. It's stored in `content` instead.
|
||||
// Making the property optional can open a small can of worms, but just ignoring it in here should be fine ;)
|
||||
return {
|
||||
...rest,
|
||||
content,
|
||||
t: 'e2e' as const,
|
||||
e2e: 'pending' as const,
|
||||
} as IE2EEMessage;
|
||||
}
|
||||
|
||||
async decryptContent<T extends EncryptedMessageContent>(data: T) {
|
||||
const content = await this.decrypt(data.content);
|
||||
Object.assign(data, content);
|
||||
return data;
|
||||
}
|
||||
|
||||
async decryptMessageContent(message: IE2EEMessage): Promise<IE2EEMessage> {
|
||||
const { attachments } = message;
|
||||
const deletedAttachments = attachments?.filter((att) => isRemovedFileAttachment(att)) || [];
|
||||
const deletedAllAttachment = deletedAttachments.find((att) => !att.fileId);
|
||||
|
||||
const content = await this.decrypt(message.content);
|
||||
Object.assign(message, content);
|
||||
|
||||
// If the encrypted message had deleted files and the decrypted message has files, compare both lists to remove from the final result any file that was flagged as deleted
|
||||
if (!deletedAttachments.length || !message.attachments?.length || !content.attachments?.length) {
|
||||
return message;
|
||||
}
|
||||
|
||||
message.attachments = message.attachments.map((att) => {
|
||||
if (!isFileAttachment(att)) {
|
||||
return att;
|
||||
}
|
||||
|
||||
if (deletedAllAttachment) {
|
||||
return deletedAllAttachment;
|
||||
}
|
||||
|
||||
const fileId = att.fileId || message.file?._id;
|
||||
if (!fileId) {
|
||||
return att;
|
||||
}
|
||||
|
||||
for (const removedAttachment of deletedAttachments) {
|
||||
if (removedAttachment.fileId === fileId) {
|
||||
return removedAttachment;
|
||||
}
|
||||
}
|
||||
|
||||
return att;
|
||||
});
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
// Decrypt messages
|
||||
async decryptMessage(message: IMessage | IE2EEMessage): Promise<IE2EEMessage | IMessage> {
|
||||
if (message.t !== 'e2e' || message.e2e === 'done') {
|
||||
return message;
|
||||
}
|
||||
|
||||
if (isEncryptedMessageContent(message)) {
|
||||
return {
|
||||
...(await this.decryptMessageContent({ ...message })),
|
||||
e2e: 'done' as const,
|
||||
};
|
||||
}
|
||||
|
||||
if (!message.msg) {
|
||||
return {
|
||||
...message,
|
||||
e2e: 'done' as const,
|
||||
};
|
||||
}
|
||||
|
||||
// TODO(@cardoso): review backward compatibility
|
||||
const data = await this.decrypt(message.msg);
|
||||
|
||||
return {
|
||||
...message,
|
||||
msg: typeof data.msg === 'string' ? data.msg : message.msg,
|
||||
e2e: 'done' as const,
|
||||
};
|
||||
}
|
||||
|
||||
async decrypt(message: string | EncryptedContent): Promise<Pick<Partial<IMessage>, 'attachments' | 'files' | 'file' | 'msg'>> {
|
||||
const span = log.span('decrypt').set('rid', this.roomId);
|
||||
const payload = decodeEncryptedContent(message);
|
||||
span.set('payload', payload);
|
||||
const { kid, iv, ciphertext } = payload;
|
||||
|
||||
const key = this.retrieveDecryptionKey(kid);
|
||||
|
||||
if (!key) {
|
||||
span.error('No decryption key found.');
|
||||
return { msg: t('E2E_indecipherable') };
|
||||
}
|
||||
span.set('algorithm', key.algorithm.name);
|
||||
span.set('extractable', key.extractable);
|
||||
span.set('type', key.type);
|
||||
span.set('usages', key.usages.toString());
|
||||
try {
|
||||
const result = await Aes.decrypt(key, { iv, ciphertext });
|
||||
const ret: unknown = EJSON.parse(result);
|
||||
if (typeof ret !== 'object' || ret === null) {
|
||||
span.error('Decrypted message is not an object');
|
||||
return { msg: t('E2E_indecipherable') };
|
||||
}
|
||||
|
||||
if ('text' in ret && typeof ret.text === 'string' && !('msg' in ret)) {
|
||||
const { text, ...rest } = ret;
|
||||
return { msg: text, ...rest };
|
||||
}
|
||||
|
||||
if ('msg' in ret && typeof ret.msg === 'string') {
|
||||
const { msg, ...rest } = ret;
|
||||
return { msg, ...rest };
|
||||
}
|
||||
|
||||
return { ...ret };
|
||||
} catch (error) {
|
||||
span.set('error', error).error('Error decrypting message');
|
||||
return { msg: t('E2E_Key_Error') };
|
||||
}
|
||||
}
|
||||
|
||||
private retrieveDecryptionKey(kid: string): Aes.Key | null {
|
||||
const oldRoomKey = this.oldKeys?.find((key) => key.e2eKeyId === kid);
|
||||
return oldRoomKey?.E2EKey ?? this.groupSessionKey;
|
||||
}
|
||||
|
||||
provideKeyToUser(keyId: string) {
|
||||
if (this.keyID !== keyId) {
|
||||
return;
|
||||
}
|
||||
|
||||
void this.encryptKeyForOtherParticipants();
|
||||
this.setState('READY');
|
||||
}
|
||||
|
||||
onStateChange(cb: () => void) {
|
||||
this.on('STATE_CHANGED', cb);
|
||||
return () => this.off('STATE_CHANGED', cb);
|
||||
}
|
||||
|
||||
async encryptGroupKeyForParticipantsWaitingForTheKeys(users: { _id: IUser['_id']; public_key: string }[]) {
|
||||
if (!this.isReady()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mySub = Subscriptions.state.find((record) => record.rid === this.roomId);
|
||||
const decryptedOldGroupKeys = await this.exportOldRoomKeys(mySub?.oldRoomKeys);
|
||||
const usersWithKeys = await Promise.all(
|
||||
users.map(async (user) => {
|
||||
const { _id, public_key } = user;
|
||||
const key = await this.encryptGroupKeyForParticipant(public_key);
|
||||
if (decryptedOldGroupKeys) {
|
||||
const oldKeys = await this.encryptOldKeysForParticipant(public_key, decryptedOldGroupKeys);
|
||||
return { _id, key, oldKeys };
|
||||
}
|
||||
return { _id, key };
|
||||
}),
|
||||
);
|
||||
|
||||
return usersWithKeys;
|
||||
}
|
||||
}
|
||||
70
apps/meteor/client/lib/e2ee/rocketchat.e2e.spec.ts
Normal file
70
apps/meteor/client/lib/e2ee/rocketchat.e2e.spec.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { imperativeModal } from '@rocket.chat/ui-client';
|
||||
|
||||
import * as banners from '../banners';
|
||||
import { e2e } from './rocketchat.e2e';
|
||||
import { dispatchToastMessage } from '../toast';
|
||||
|
||||
jest.mock('@rocket.chat/ui-client', () => ({
|
||||
imperativeModal: {
|
||||
open: jest.fn(),
|
||||
close: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../../../client/lib/getUserAvatarURL', () => ({
|
||||
getUserAvatarURL: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../app/utils/lib/i18n', () => ({
|
||||
t: (key: string) => key,
|
||||
}));
|
||||
|
||||
jest.mock('../toast', () => ({
|
||||
dispatchToastMessage: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../banners', () => ({
|
||||
closeById: jest.fn(),
|
||||
open: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('E2E password modal', () => {
|
||||
const getModalProps = () => (imperativeModal.open as jest.Mock).mock.calls.at(-1)?.[0].props;
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('keeps the E2EE alert open when the modal header close button is used', () => {
|
||||
e2e.openEnterE2EEPasswordModal(jest.fn());
|
||||
|
||||
getModalProps().onClose();
|
||||
|
||||
expect(imperativeModal.close).toHaveBeenCalledTimes(1);
|
||||
expect(banners.closeById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('dismisses the E2EE alert when the user explicitly cancels entering the password', () => {
|
||||
e2e.openEnterE2EEPasswordModal(jest.fn());
|
||||
|
||||
getModalProps().onCancel();
|
||||
|
||||
expect(dispatchToastMessage).toHaveBeenCalledWith({ type: 'info', message: 'End_To_End_Encryption_Not_Enabled' });
|
||||
expect(imperativeModal.close).toHaveBeenCalledTimes(1);
|
||||
expect(banners.closeById).toHaveBeenCalledWith('e2e');
|
||||
});
|
||||
|
||||
it('dismisses the E2EE alert after the password is entered successfully', async () => {
|
||||
const onEnterE2EEPassword = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
e2e.openEnterE2EEPasswordModal(onEnterE2EEPassword);
|
||||
|
||||
await getModalProps().onConfirm('password');
|
||||
|
||||
expect(onEnterE2EEPassword).toHaveBeenCalledWith('password');
|
||||
expect(dispatchToastMessage).toHaveBeenCalledWith({ type: 'success', message: 'E2E_encryption_enabled' });
|
||||
expect(imperativeModal.close).toHaveBeenCalledTimes(1);
|
||||
expect(banners.closeById).toHaveBeenCalledWith('e2e');
|
||||
});
|
||||
});
|
||||
884
apps/meteor/client/lib/e2ee/rocketchat.e2e.ts
Normal file
884
apps/meteor/client/lib/e2ee/rocketchat.e2e.ts
Normal file
@@ -0,0 +1,884 @@
|
||||
import type { IE2EEMessage, IMessage, IRoom, IUser, IUploadWithUser, Serialized, IE2EEPinnedMessage } from '@rocket.chat/core-typings';
|
||||
import { isE2EEMessage, isEncryptedMessageContent } from '@rocket.chat/core-typings';
|
||||
import { Emitter } from '@rocket.chat/emitter';
|
||||
import { isTruthy } from '@rocket.chat/tools';
|
||||
import { imperativeModal } from '@rocket.chat/ui-client';
|
||||
import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts';
|
||||
import sampleSize from 'lodash/sampleSize';
|
||||
|
||||
import type { E2EEState } from './E2EEState';
|
||||
import * as Rsa from './crypto/rsa';
|
||||
import { generatePassphrase } from './helper';
|
||||
import { Keychain } from './keychain';
|
||||
import { createLogger } from './logger';
|
||||
import { E2ERoom } from './rocketchat.e2e.room';
|
||||
import { t } from '../../../app/utils/lib/i18n';
|
||||
import { createQuoteAttachment } from '../../../lib/createQuoteAttachment';
|
||||
import { getMessageUrlRegex } from '../../../lib/getMessageUrlRegex';
|
||||
import { Rooms, Subscriptions } from '../../stores';
|
||||
import EnterE2EPasswordModal from '../../views/e2e/EnterE2EPasswordModal';
|
||||
import SaveE2EPasswordModal from '../../views/e2e/SaveE2EPasswordModal';
|
||||
import { sdk } from '../SDKClient';
|
||||
import * as banners from '../banners';
|
||||
import type { LegacyBannerPayload } from '../banners';
|
||||
import { getUserAvatarURL } from '../getUserAvatarURL';
|
||||
import { limitQuoteChain } from '../limitQuoteChain';
|
||||
import { getDdpSdk } from '../sdk/ddpSdk';
|
||||
import { STORAGE_KEYS, getStoredItem, removeStoredItem, setStoredItem } from '../sdk/storage';
|
||||
import { settings } from '../settings';
|
||||
import { dispatchToastMessage } from '../toast';
|
||||
import { mapMessageFromApi } from '../utils/mapMessageFromApi';
|
||||
|
||||
let failedToDecodeKey = false;
|
||||
|
||||
const log = createLogger('E2E');
|
||||
|
||||
type KeyPair = {
|
||||
public_key: string | null;
|
||||
private_key: string | null;
|
||||
};
|
||||
|
||||
const ROOM_KEY_EXCHANGE_SIZE = 10;
|
||||
|
||||
class E2E extends Emitter {
|
||||
private userId: string | false = false;
|
||||
|
||||
private keychain: Keychain;
|
||||
|
||||
private instancesByRoomId: Record<IRoom['_id'], E2ERoom> = {};
|
||||
|
||||
private db_public_key: string | null | undefined;
|
||||
|
||||
private db_private_key: string | null | undefined;
|
||||
|
||||
public privateKey: Rsa.PrivateKey | undefined;
|
||||
|
||||
public publicKey: string | undefined;
|
||||
|
||||
private keyDistributionInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
private state: E2EEState;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.on('READY', async () => {
|
||||
await this.onE2EEReady();
|
||||
});
|
||||
|
||||
this.on('SAVE_PASSWORD', async () => {
|
||||
await this.onE2EEReady();
|
||||
});
|
||||
|
||||
this.on('DISABLED', () => {
|
||||
this.unsubscribeFromSubscriptions?.();
|
||||
});
|
||||
|
||||
this.on('NOT_STARTED', () => {
|
||||
this.unsubscribeFromSubscriptions?.();
|
||||
});
|
||||
|
||||
this.on('ERROR', () => {
|
||||
this.unsubscribeFromSubscriptions?.();
|
||||
});
|
||||
|
||||
this.setState('NOT_STARTED');
|
||||
}
|
||||
|
||||
getState() {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
isEnabled(): boolean {
|
||||
return this.state !== 'DISABLED';
|
||||
}
|
||||
|
||||
isReady(): boolean {
|
||||
// Save_Password state is also a ready state for E2EE
|
||||
return this.state === 'READY' || this.state === 'SAVE_PASSWORD';
|
||||
}
|
||||
|
||||
async onE2EEReady() {
|
||||
this.initiateHandshake();
|
||||
await this.handleAsyncE2EESuggestedKey();
|
||||
await this.decryptSubscriptions();
|
||||
await this.initiateKeyDistribution();
|
||||
this.observeSubscriptions();
|
||||
}
|
||||
|
||||
async onSubscriptionChanged(sub: SubscriptionWithRoom): Promise<void> {
|
||||
const span = log.span('onSubscriptionChanged').set('subscription_id', sub._id).set('room_id', sub.rid).set('encrypted', sub.encrypted);
|
||||
if (!sub.encrypted && !sub.E2EKey) {
|
||||
this.removeInstanceByRoomId(sub.rid);
|
||||
return;
|
||||
}
|
||||
|
||||
const e2eRoom = await this.getInstanceByRoomId(sub.rid);
|
||||
if (!e2eRoom) {
|
||||
span.warn('no e2eRoom found');
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub.E2ESuggestedKey) {
|
||||
if (await e2eRoom.importGroupKey(sub.E2ESuggestedKey)) {
|
||||
await this.acceptSuggestedKey(sub.rid);
|
||||
e2eRoom.keyReceived();
|
||||
} else {
|
||||
span.warn('rejected');
|
||||
await this.rejectSuggestedKey(sub.rid);
|
||||
}
|
||||
}
|
||||
|
||||
if (sub.encrypted) {
|
||||
e2eRoom.resume();
|
||||
} else {
|
||||
e2eRoom.pause();
|
||||
}
|
||||
|
||||
// Cover private groups and direct messages
|
||||
if (!e2eRoom.isSupportedRoomType(sub.t)) {
|
||||
e2eRoom.disable();
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub.E2EKey && e2eRoom.isWaitingKeys()) {
|
||||
e2eRoom.keyReceived();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!e2eRoom.isReady()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub.lastMessage?.e2e !== 'done') {
|
||||
await e2eRoom.decryptSubscription();
|
||||
}
|
||||
}
|
||||
|
||||
private unsubscribeFromSubscriptions: (() => void) | undefined;
|
||||
|
||||
observeSubscriptions() {
|
||||
const span = log.span('observeSubscriptions');
|
||||
this.unsubscribeFromSubscriptions?.();
|
||||
|
||||
this.unsubscribeFromSubscriptions = Subscriptions.use.subscribe((state) => {
|
||||
const subscriptions = Array.from(state.records.values()).filter((sub) => sub.encrypted || sub.E2EKey);
|
||||
|
||||
const subscribed = new Set(subscriptions.map((sub) => sub.rid));
|
||||
const instatiated = new Set(Object.keys(this.instancesByRoomId));
|
||||
const excess = instatiated.difference(subscribed);
|
||||
|
||||
if (excess.size) {
|
||||
span.info('Unsubscribing from excess instances');
|
||||
excess.forEach((rid) => this.removeInstanceByRoomId(rid));
|
||||
}
|
||||
|
||||
for (const sub of subscriptions) {
|
||||
void this.onSubscriptionChanged(sub);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setState(nextState: E2EEState) {
|
||||
const span = log.span('setState').set('prevState', this.state).set('nextState', nextState);
|
||||
const prevState = this.state;
|
||||
|
||||
this.state = nextState;
|
||||
|
||||
span.info(`${prevState} -> ${nextState}`);
|
||||
this.emit('E2E_STATE_CHANGED', { prevState, nextState });
|
||||
|
||||
this.emit(nextState);
|
||||
}
|
||||
|
||||
async handleAsyncE2EESuggestedKey() {
|
||||
const span = log.span('handleAsyncE2EESuggestedKey');
|
||||
const subs = Subscriptions.state.filter((sub) => typeof sub.E2ESuggestedKey !== 'undefined');
|
||||
await Promise.all(
|
||||
subs
|
||||
.filter((sub) => sub.E2ESuggestedKey && !sub.E2EKey)
|
||||
.map(async (sub) => {
|
||||
const e2eRoom = await e2e.getInstanceByRoomId(sub.rid);
|
||||
span.set('subscription_id', sub._id).set('room_id', sub.rid);
|
||||
|
||||
if (!e2eRoom) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub.E2ESuggestedKey && (await e2eRoom.importGroupKey(sub.E2ESuggestedKey))) {
|
||||
span.info('importedE2ESuggestedKey');
|
||||
await e2e.acceptSuggestedKey(sub.rid);
|
||||
e2eRoom.keyReceived();
|
||||
} else {
|
||||
span.error('invalidE2ESuggestedKey');
|
||||
await e2e.rejectSuggestedKey(sub.rid);
|
||||
}
|
||||
|
||||
if (sub.encrypted) {
|
||||
e2eRoom.resume();
|
||||
} else {
|
||||
e2eRoom.pause();
|
||||
}
|
||||
}),
|
||||
);
|
||||
span.info('handledAsyncE2ESuggestedKey');
|
||||
}
|
||||
|
||||
private waitForRoom(rid: IRoom['_id']): Promise<IRoom> {
|
||||
return new Promise((resolve) => {
|
||||
const room = Rooms.state.get(rid);
|
||||
|
||||
if (room) resolve(room);
|
||||
|
||||
const unsubscribe = Rooms.use.subscribe((state) => {
|
||||
const room = state.get(rid);
|
||||
if (room) {
|
||||
unsubscribe();
|
||||
resolve(room);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async getInstanceByRoomId(rid: IRoom['_id']): Promise<E2ERoom | null> {
|
||||
if (!this.userId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const room = await this.waitForRoom(rid);
|
||||
|
||||
if (room.t !== 'd' && room.t !== 'p') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!room.encrypted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!this.instancesByRoomId[rid] && this.userId) {
|
||||
this.instancesByRoomId[rid] = new E2ERoom(this.userId, room);
|
||||
}
|
||||
|
||||
// When the key was already set and is changed via an update, we update the room instance
|
||||
if (
|
||||
this.instancesByRoomId[rid].keyID !== undefined &&
|
||||
room.e2eKeyId !== undefined &&
|
||||
this.instancesByRoomId[rid].keyID !== room.e2eKeyId
|
||||
) {
|
||||
// KeyID was changed, update instance with new keyID and put room in waiting keys status
|
||||
this.instancesByRoomId[rid].onRoomKeyReset(room.e2eKeyId);
|
||||
}
|
||||
|
||||
return this.instancesByRoomId[rid] ?? null;
|
||||
}
|
||||
|
||||
removeInstanceByRoomId(rid: IRoom['_id']): void {
|
||||
delete this.instancesByRoomId[rid];
|
||||
}
|
||||
|
||||
private async persistKeys(
|
||||
{ public_key, private_key }: KeyPair,
|
||||
password: string,
|
||||
{ force }: { force: boolean } = { force: false },
|
||||
): Promise<void> {
|
||||
if (typeof public_key !== 'string' || typeof private_key !== 'string') {
|
||||
throw new Error('Failed to persist keys as they are not strings.');
|
||||
}
|
||||
|
||||
const encodedPrivateKey = await this.keychain.encryptKey(private_key, password);
|
||||
|
||||
if (!encodedPrivateKey) {
|
||||
throw new Error('Failed to encode private key with provided password.');
|
||||
}
|
||||
|
||||
await sdk.rest.post('/v1/e2e.setUserPublicAndPrivateKeys', {
|
||||
public_key,
|
||||
private_key: JSON.stringify(encodedPrivateKey),
|
||||
force,
|
||||
});
|
||||
}
|
||||
|
||||
async acceptSuggestedKey(rid: string): Promise<void> {
|
||||
await sdk.rest.post('/v1/e2e.acceptSuggestedGroupKey', {
|
||||
rid,
|
||||
});
|
||||
}
|
||||
|
||||
async rejectSuggestedKey(rid: string): Promise<void> {
|
||||
await sdk.rest.post('/v1/e2e.rejectSuggestedGroupKey', {
|
||||
rid,
|
||||
});
|
||||
}
|
||||
|
||||
getKeysFromLocalStorage(): KeyPair {
|
||||
return {
|
||||
public_key: getStoredItem(STORAGE_KEYS.E2EE_PUBLIC_KEY),
|
||||
private_key: getStoredItem(STORAGE_KEYS.E2EE_PRIVATE_KEY),
|
||||
};
|
||||
}
|
||||
|
||||
initiateHandshake() {
|
||||
Object.keys(this.instancesByRoomId).map((key) => this.instancesByRoomId[key].handshake());
|
||||
}
|
||||
|
||||
openSaveE2EEPasswordModal(randomPassword: string) {
|
||||
imperativeModal.open({
|
||||
component: SaveE2EPasswordModal,
|
||||
props: {
|
||||
randomPassword,
|
||||
onClose: imperativeModal.close,
|
||||
onCancel: () => {
|
||||
this.closeAlert();
|
||||
imperativeModal.close();
|
||||
},
|
||||
onConfirm: () => {
|
||||
removeStoredItem(STORAGE_KEYS.E2EE_RANDOM_PASSWORD);
|
||||
this.setState('READY');
|
||||
dispatchToastMessage({ type: 'success', message: t('E2E_encryption_enabled') });
|
||||
this.closeAlert();
|
||||
imperativeModal.close();
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async startClient(userId: string): Promise<void> {
|
||||
if (!isSecureContext) {
|
||||
throw new Error('E2E encryption can only be enabled in secure contexts (HTTPS)');
|
||||
}
|
||||
|
||||
const span = log.span('startClient');
|
||||
if (this.userId === userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
span.info(this.state);
|
||||
|
||||
this.userId = userId;
|
||||
this.keychain = new Keychain(userId);
|
||||
|
||||
let { public_key, private_key } = this.getKeysFromLocalStorage();
|
||||
|
||||
await this.loadKeysFromDB();
|
||||
|
||||
if (!public_key && this.db_public_key) {
|
||||
public_key = this.db_public_key;
|
||||
}
|
||||
|
||||
if (this.db_private_key && !private_key) {
|
||||
try {
|
||||
this.setState('ENTER_PASSWORD');
|
||||
private_key = await this.decodePrivateKey(this.db_private_key);
|
||||
} catch (error) {
|
||||
this.userId = false;
|
||||
failedToDecodeKey = true;
|
||||
this.openAlert({
|
||||
title: "Wasn't possible to decode your encryption key to be imported.", // TODO: missing translation
|
||||
html: '<div>Your encryption password seems wrong. Click here to try again.</div>', // TODO: missing translation
|
||||
modifiers: ['large', 'danger'],
|
||||
closable: true,
|
||||
icon: 'key',
|
||||
action: async () => {
|
||||
await this.startClient(userId);
|
||||
this.closeAlert();
|
||||
},
|
||||
});
|
||||
return span.error('E2E -> Error decoding private key: ', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (public_key && private_key) {
|
||||
await this.loadKeys({ public_key, private_key });
|
||||
this.setState('READY');
|
||||
} else {
|
||||
await this.createAndLoadKeys();
|
||||
this.setState('READY');
|
||||
}
|
||||
|
||||
if (!this.db_public_key || !this.db_private_key) {
|
||||
this.setState('LOADING_KEYS');
|
||||
await this.persistKeys(this.getKeysFromLocalStorage(), await this.createRandomPassword());
|
||||
}
|
||||
|
||||
const randomPassword = getStoredItem(STORAGE_KEYS.E2EE_RANDOM_PASSWORD);
|
||||
if (randomPassword) {
|
||||
this.setState('SAVE_PASSWORD');
|
||||
this.openAlert({
|
||||
title: () => t('Save_your_new_E2EE_password'),
|
||||
html: () => t('Click_here_to_view_and_save_your_new_E2EE_password'),
|
||||
modifiers: ['large'],
|
||||
closable: false,
|
||||
icon: 'key',
|
||||
action: () => this.openSaveE2EEPasswordModal(randomPassword),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async stopClient(): Promise<void> {
|
||||
const span = log.span('stopClient');
|
||||
span.info(this.state);
|
||||
this.closeAlert();
|
||||
|
||||
removeStoredItem(STORAGE_KEYS.E2EE_PUBLIC_KEY);
|
||||
removeStoredItem(STORAGE_KEYS.E2EE_PRIVATE_KEY);
|
||||
this.instancesByRoomId = {};
|
||||
this.privateKey = undefined;
|
||||
this.publicKey = undefined;
|
||||
this.userId = false;
|
||||
if (this.keyDistributionInterval) {
|
||||
clearInterval(this.keyDistributionInterval);
|
||||
}
|
||||
this.keyDistributionInterval = null;
|
||||
this.setState('DISABLED');
|
||||
}
|
||||
|
||||
async changePassword(newPassword: string): Promise<void> {
|
||||
await this.persistKeys(this.getKeysFromLocalStorage(), newPassword, { force: true });
|
||||
|
||||
if (getStoredItem(STORAGE_KEYS.E2EE_RANDOM_PASSWORD)) {
|
||||
setStoredItem(STORAGE_KEYS.E2EE_RANDOM_PASSWORD, newPassword);
|
||||
}
|
||||
}
|
||||
|
||||
async loadKeysFromDB(): Promise<void> {
|
||||
const span = log.span('loadKeysFromDB');
|
||||
try {
|
||||
this.setState('LOADING_KEYS');
|
||||
const { public_key, private_key } = await sdk.rest.get('/v1/e2e.fetchMyKeys');
|
||||
|
||||
this.db_public_key = public_key;
|
||||
this.db_private_key = private_key;
|
||||
|
||||
span.info('fetched keys from db');
|
||||
} catch (error) {
|
||||
this.setState('ERROR');
|
||||
span.error('Error fetching RSA keys: ', error);
|
||||
// Stop any process since we can't communicate with the server
|
||||
// to get the keys. This prevents new key generation
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async loadKeys({ public_key, private_key }: { public_key: string; private_key: string }): Promise<void> {
|
||||
const span = log.span('loadKeys');
|
||||
setStoredItem(STORAGE_KEYS.E2EE_PUBLIC_KEY, public_key);
|
||||
this.publicKey = public_key;
|
||||
|
||||
try {
|
||||
this.privateKey = await Rsa.importPrivateKey(JSON.parse(private_key));
|
||||
|
||||
setStoredItem(STORAGE_KEYS.E2EE_PRIVATE_KEY, private_key);
|
||||
} catch (error) {
|
||||
this.setState('ERROR');
|
||||
return span.error('Error importing private key: ', error);
|
||||
}
|
||||
}
|
||||
|
||||
async createAndLoadKeys(): Promise<void> {
|
||||
const span = log.span('createAndLoadKeys');
|
||||
// Could not obtain public-private keypair from server.
|
||||
this.setState('LOADING_KEYS');
|
||||
let keyPair;
|
||||
try {
|
||||
keyPair = await Rsa.generate();
|
||||
this.privateKey = keyPair.privateKey;
|
||||
} catch (error) {
|
||||
this.setState('ERROR');
|
||||
return span.set('error', error).error('Error generating key');
|
||||
}
|
||||
|
||||
try {
|
||||
const publicKey = await Rsa.exportPublicKey(keyPair.publicKey);
|
||||
|
||||
this.publicKey = JSON.stringify(publicKey);
|
||||
setStoredItem(STORAGE_KEYS.E2EE_PUBLIC_KEY, JSON.stringify(publicKey));
|
||||
} catch (error) {
|
||||
this.setState('ERROR');
|
||||
return span.set('error', error).error('Error exporting public key');
|
||||
}
|
||||
|
||||
try {
|
||||
const privateKey = await Rsa.exportPrivateKey(keyPair.privateKey);
|
||||
|
||||
setStoredItem(STORAGE_KEYS.E2EE_PRIVATE_KEY, JSON.stringify(privateKey));
|
||||
} catch (error) {
|
||||
this.setState('ERROR');
|
||||
return span.set('error', error).error('Error exporting private key');
|
||||
}
|
||||
|
||||
await this.requestSubscriptionKeys();
|
||||
}
|
||||
|
||||
async requestSubscriptionKeys(): Promise<void> {
|
||||
await sdk.rest.post('/v1/e2e.requestSubscriptionKeys');
|
||||
}
|
||||
|
||||
async createRandomPassword(): Promise<string> {
|
||||
const randomPassword = await generatePassphrase();
|
||||
setStoredItem(STORAGE_KEYS.E2EE_RANDOM_PASSWORD, randomPassword);
|
||||
return randomPassword;
|
||||
}
|
||||
|
||||
openEnterE2EEPasswordModal(onEnterE2EEPassword: (password: string) => Promise<void>) {
|
||||
const close = () => {
|
||||
imperativeModal.close();
|
||||
};
|
||||
const dismiss = () => {
|
||||
this.closeAlert();
|
||||
imperativeModal.close();
|
||||
};
|
||||
imperativeModal.open({
|
||||
component: EnterE2EPasswordModal,
|
||||
props: {
|
||||
onClose: close,
|
||||
onCancel: () => {
|
||||
failedToDecodeKey = false;
|
||||
dispatchToastMessage({ type: 'info', message: t('End_To_End_Encryption_Not_Enabled') });
|
||||
dismiss();
|
||||
},
|
||||
onConfirm: async (password) => {
|
||||
await onEnterE2EEPassword(password);
|
||||
dispatchToastMessage({ type: 'success', message: t('E2E_encryption_enabled') });
|
||||
dismiss();
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async requestPasswordAlert(validatePassword: (password: string) => Promise<void>): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const showModal = () =>
|
||||
this.openEnterE2EEPasswordModal(async (password) => {
|
||||
await validatePassword(password);
|
||||
resolve();
|
||||
});
|
||||
|
||||
const showAlert = () => {
|
||||
this.openAlert({
|
||||
title: () => t('Enter_your_E2E_password'),
|
||||
html: () => t('Click_here_to_enter_your_password'),
|
||||
modifiers: ['large'],
|
||||
closable: false,
|
||||
icon: 'key',
|
||||
action() {
|
||||
showModal();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (failedToDecodeKey) {
|
||||
showModal();
|
||||
} else {
|
||||
showAlert();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async requestPasswordModal(validatePassword: (password: string) => Promise<void>): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
this.openEnterE2EEPasswordModal(async (password) => {
|
||||
await validatePassword(password);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async decodePrivateKeyFlow() {
|
||||
if (!this.db_private_key) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let privateKey: string | undefined;
|
||||
await this.requestPasswordModal(async (password) => {
|
||||
privateKey = await this.keychain.decryptKey(this.db_private_key as string, password);
|
||||
});
|
||||
|
||||
if (this.db_public_key && privateKey) {
|
||||
await this.loadKeys({ public_key: this.db_public_key, private_key: privateKey });
|
||||
} else {
|
||||
await this.createAndLoadKeys();
|
||||
}
|
||||
this.setState('READY');
|
||||
|
||||
dispatchToastMessage({ type: 'success', message: t('E2E_encryption_enabled') });
|
||||
} catch (error) {
|
||||
this.setState('ENTER_PASSWORD');
|
||||
dispatchToastMessage({ type: 'info', message: t('End_To_End_Encryption_Not_Enabled') });
|
||||
throw new Error('E2E -> Error loading keys', { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
async decodePrivateKey(privateKey: string): Promise<string> {
|
||||
let decryptedKey: string | undefined;
|
||||
await this.requestPasswordAlert(async (password) => {
|
||||
decryptedKey = await this.keychain.decryptKey(privateKey, password);
|
||||
});
|
||||
return decryptedKey as string;
|
||||
}
|
||||
|
||||
async decryptFileContent(file: IUploadWithUser): Promise<IUploadWithUser> {
|
||||
if (!file.rid || !isEncryptedMessageContent(file)) {
|
||||
return file;
|
||||
}
|
||||
|
||||
const e2eRoom = await this.getInstanceByRoomId(file.rid);
|
||||
|
||||
if (!e2eRoom) {
|
||||
return file;
|
||||
}
|
||||
|
||||
return e2eRoom.decryptContent(file);
|
||||
}
|
||||
|
||||
async decryptMessage(message: IMessage | IE2EEMessage): Promise<IMessage> {
|
||||
if (!isE2EEMessage(message) || message.e2e === 'done') {
|
||||
return message;
|
||||
}
|
||||
|
||||
const e2eRoom = await this.getInstanceByRoomId(message.rid);
|
||||
|
||||
if (!e2eRoom) {
|
||||
return message;
|
||||
}
|
||||
|
||||
const decryptedMessage = (await e2eRoom.decryptMessage(message)) as IE2EEMessage;
|
||||
|
||||
const decryptedMessageWithQuote = await this.parseQuoteAttachment(decryptedMessage);
|
||||
|
||||
return decryptedMessageWithQuote;
|
||||
}
|
||||
|
||||
async decryptPinnedMessage(message: IE2EEPinnedMessage) {
|
||||
const span = log.span('decryptPinnedMessage');
|
||||
const [pinnedMessage] = message.attachments;
|
||||
|
||||
if (!pinnedMessage) {
|
||||
span.warn('No pinned message found');
|
||||
return message;
|
||||
}
|
||||
|
||||
const e2eRoom = await this.getInstanceByRoomId(message.rid);
|
||||
|
||||
if (!e2eRoom) {
|
||||
span.warn('No e2eRoom found');
|
||||
return message;
|
||||
}
|
||||
|
||||
const data = await e2eRoom.decrypt(pinnedMessage.content);
|
||||
|
||||
// TODO(@cardoso): review backward compatibility
|
||||
message.attachments[0].text = data.msg;
|
||||
|
||||
span.info('pinned message decrypted');
|
||||
return message;
|
||||
}
|
||||
|
||||
async decryptSubscription(subscription: SubscriptionWithRoom): Promise<void> {
|
||||
const span = log.span('decryptSubscription');
|
||||
const e2eRoom = await this.getInstanceByRoomId(subscription.rid);
|
||||
span.info(subscription._id);
|
||||
|
||||
if (!e2eRoom) {
|
||||
span.warn('no e2eRoom found');
|
||||
return;
|
||||
}
|
||||
|
||||
if (e2eRoom.isReady()) {
|
||||
span.info('e2e room ready');
|
||||
await e2eRoom.decryptSubscription();
|
||||
return;
|
||||
}
|
||||
|
||||
e2eRoom.once('READY', async () => {
|
||||
span.info('e2e room ready');
|
||||
await e2eRoom.decryptSubscription();
|
||||
});
|
||||
}
|
||||
|
||||
async decryptSubscriptions(): Promise<void> {
|
||||
const subscriptions = Subscriptions.state.filter((subscription) => !!subscription.encrypted);
|
||||
|
||||
await Promise.all(
|
||||
subscriptions.map(async (subscription) => {
|
||||
await this.decryptSubscription(subscription);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
openAlert(config: Omit<LegacyBannerPayload, 'id'>): void {
|
||||
banners.open({ id: 'e2e', ...config });
|
||||
}
|
||||
|
||||
closeAlert(): void {
|
||||
banners.closeById('e2e');
|
||||
}
|
||||
|
||||
async parseQuoteAttachment(message: IE2EEMessage): Promise<IE2EEMessage> {
|
||||
if (!message?.msg) {
|
||||
return message;
|
||||
}
|
||||
const urls = message.msg.match(getMessageUrlRegex()) || [];
|
||||
|
||||
const siteUrl = settings.peek<string>('Site_Url');
|
||||
|
||||
await Promise.all(
|
||||
urls.map(async (url) => {
|
||||
if (siteUrl && !url.includes(siteUrl)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// URLs come from regex-matched message content, so they may be malformed; skip those instead of throwing
|
||||
let urlObj: URL;
|
||||
try {
|
||||
urlObj = new URL(url);
|
||||
} catch (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [msgId, ...extraMsgIds] = urlObj.searchParams.getAll('msg');
|
||||
|
||||
// skip if the msg param is missing, empty, or duplicated
|
||||
if (!msgId || extraMsgIds.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
let quotedMessage: Serialized<IMessage>;
|
||||
try {
|
||||
const getQuotedMessage = await sdk.rest.get('/v1/chat.getMessage', { msgId });
|
||||
quotedMessage = getQuotedMessage?.message;
|
||||
} catch (error) {
|
||||
console.error(`Error getting quoted message: ${error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!quotedMessage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const decryptedQuoteMessage = await this.decryptMessage(mapMessageFromApi(quotedMessage));
|
||||
|
||||
message.attachments = message.attachments || [];
|
||||
const useRealName = settings.peek('UI_Use_Real_Name');
|
||||
const quoteAttachment = createQuoteAttachment(
|
||||
decryptedQuoteMessage,
|
||||
url,
|
||||
useRealName,
|
||||
getUserAvatarURL(decryptedQuoteMessage.u.username || '') as string,
|
||||
);
|
||||
|
||||
message.attachments.push(limitQuoteChain(quoteAttachment, settings.peek('Message_QuoteChainLimit') ?? 2));
|
||||
}),
|
||||
);
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
async getSuggestedE2EEKeys(usersWaitingForE2EKeys: Record<IRoom['_id'], { _id: IUser['_id']; public_key: string }[]>) {
|
||||
const roomIds = Object.keys(usersWaitingForE2EKeys);
|
||||
return Object.fromEntries(
|
||||
(
|
||||
await Promise.all(
|
||||
roomIds.map(async (room) => {
|
||||
const e2eRoom = await this.getInstanceByRoomId(room);
|
||||
|
||||
if (!e2eRoom) {
|
||||
return;
|
||||
}
|
||||
const usersWithKeys = await e2eRoom.encryptGroupKeyForParticipantsWaitingForTheKeys(usersWaitingForE2EKeys[room]);
|
||||
|
||||
if (!usersWithKeys) {
|
||||
return;
|
||||
}
|
||||
|
||||
return [room, usersWithKeys];
|
||||
}),
|
||||
)
|
||||
).filter(isTruthy),
|
||||
);
|
||||
}
|
||||
|
||||
async getSample(roomIds: string[], limit = 3): Promise<string[]> {
|
||||
if (limit === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const randomRoomIds = sampleSize(roomIds, ROOM_KEY_EXCHANGE_SIZE);
|
||||
|
||||
const sampleIds: string[] = [];
|
||||
for await (const roomId of randomRoomIds) {
|
||||
const e2eroom = await this.getInstanceByRoomId(roomId);
|
||||
if (!e2eroom?.hasSessionKey()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
sampleIds.push(roomId);
|
||||
}
|
||||
|
||||
if (!sampleIds.length) {
|
||||
return this.getSample(roomIds, limit - 1);
|
||||
}
|
||||
|
||||
return sampleIds;
|
||||
}
|
||||
|
||||
getUserId(): string {
|
||||
if (!this.userId) {
|
||||
throw new Error('No userId found');
|
||||
}
|
||||
return this.userId;
|
||||
}
|
||||
|
||||
async initiateKeyDistribution() {
|
||||
if (this.keyDistributionInterval) {
|
||||
return;
|
||||
}
|
||||
|
||||
const predicate = (record: IRoom) =>
|
||||
Boolean('usersWaitingForE2EKeys' in record && record.usersWaitingForE2EKeys?.every((user) => user.userId !== this.getUserId()));
|
||||
|
||||
const span = log.span('initiateKeyDistribution');
|
||||
const keyDistribution = async () => {
|
||||
const roomIds = Rooms.state.filter(predicate).map((room) => room._id);
|
||||
|
||||
if (!roomIds.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent function from running and doing nothing when theres something to do
|
||||
const sampleIds = await this.getSample(roomIds);
|
||||
|
||||
if (!sampleIds.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { usersWaitingForE2EKeys = {} } = await sdk.rest.get('/v1/e2e.fetchUsersWaitingForGroupKey', { roomIds: sampleIds });
|
||||
|
||||
if (!Object.keys(usersWaitingForE2EKeys).length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userKeysWithRooms = await this.getSuggestedE2EEKeys(usersWaitingForE2EKeys);
|
||||
|
||||
if (!Object.keys(userKeysWithRooms).length) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await sdk.rest.post('/v1/e2e.provideUsersSuggestedGroupKeys', { usersSuggestedGroupKeys: userKeysWithRooms });
|
||||
} catch (error) {
|
||||
return span.error('provideUsersSuggestedGroupKeys', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Run first call right away, then schedule for 10s in the future
|
||||
await keyDistribution();
|
||||
this.keyDistributionInterval = setInterval(keyDistribution, 10000);
|
||||
}
|
||||
}
|
||||
|
||||
export const e2e = new E2E();
|
||||
|
||||
getDdpSdk().account.onLogout(() => {
|
||||
void e2e.stopClient();
|
||||
});
|
||||
2054
apps/meteor/client/lib/e2ee/wordList.ts
Normal file
2054
apps/meteor/client/lib/e2ee/wordList.ts
Normal file
File diff suppressed because it is too large
Load Diff
98
apps/meteor/client/lib/emoji/emojiParser.ts
Normal file
98
apps/meteor/client/lib/emoji/emojiParser.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { emoji } from './lib';
|
||||
|
||||
// Escapes a plain text value into its HTML representation so it can be safely
|
||||
// reinserted via innerHTML after emoji rendering (e.g. `<` becomes `<`).
|
||||
const escapeHtml = (value: string): string => {
|
||||
const escaper = document.createElement('div');
|
||||
escaper.textContent = value;
|
||||
return escaper.innerHTML;
|
||||
};
|
||||
|
||||
const renderEmojiPackages = (value: string): string =>
|
||||
Object.entries(emoji.packages)
|
||||
.reverse()
|
||||
.reduce((rendered, [, emojiPackage]) => emojiPackage.render(rendered), value);
|
||||
|
||||
/**
|
||||
* emojiParser is a function that will replace emojis
|
||||
*/
|
||||
export const emojiParser = (html: string) => {
|
||||
html = html.trim();
|
||||
|
||||
// ' to apostrophe (') for emojis such as :')
|
||||
html = html.replace(/'/g, "'");
|
||||
|
||||
// '<br>' to ' <br> ' for emojis such at line breaks
|
||||
html = html.replace(/<br>/g, ' <br> ');
|
||||
|
||||
const checkEmojiOnly = document.createElement('div');
|
||||
|
||||
checkEmojiOnly.innerHTML = html;
|
||||
|
||||
// Render emojis only inside text nodes so shortcodes that live in markup
|
||||
// (e.g. a URL containing `:smile:` in an anchor's href) stay untouched.
|
||||
// Emojis inside code blocks are intentionally left as their literal shortcode.
|
||||
const textWalker = document.createTreeWalker(checkEmojiOnly, NodeFilter.SHOW_TEXT, {
|
||||
acceptNode: (node) => (node.parentElement?.closest('code') ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT),
|
||||
});
|
||||
|
||||
const textNodes: Text[] = [];
|
||||
while (textWalker.nextNode()) {
|
||||
textNodes.push(textWalker.currentNode as Text);
|
||||
}
|
||||
|
||||
textNodes.forEach((textNode) => {
|
||||
const escaped = escapeHtml(textNode.nodeValue ?? '');
|
||||
const rendered = renderEmojiPackages(escaped);
|
||||
|
||||
if (rendered === escaped) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fragmentHolder = document.createElement('span');
|
||||
fragmentHolder.innerHTML = rendered;
|
||||
textNode.replaceWith(...Array.from(fragmentHolder.childNodes));
|
||||
});
|
||||
|
||||
const emojis = Array.from(checkEmojiOnly.querySelectorAll('.emoji:not(:empty)'));
|
||||
|
||||
let hasText = false;
|
||||
|
||||
const isElement = (node: Node): node is Element => node.nodeType === Node.ELEMENT_NODE;
|
||||
|
||||
const isTextNode = (node: Node): node is Text => node.nodeType === Node.TEXT_NODE;
|
||||
|
||||
const filter = (node: Node) => {
|
||||
if (isElement(node) && node.classList.contains('emoji')) {
|
||||
return NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
return NodeFilter.FILTER_ACCEPT;
|
||||
};
|
||||
|
||||
const walker = document.createTreeWalker(checkEmojiOnly, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, filter);
|
||||
|
||||
while (walker.nextNode()) {
|
||||
if (isTextNode(walker.currentNode) && walker.currentNode.nodeValue.trim() !== '') {
|
||||
hasText = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const emojiOnly = emojis.length && !hasText;
|
||||
|
||||
if (emojiOnly) {
|
||||
for (let i = 0, len = emojis.length; i < len; i++) {
|
||||
const { classList } = emojis[i];
|
||||
classList.add('big');
|
||||
}
|
||||
}
|
||||
|
||||
html = checkEmojiOnly.innerHTML;
|
||||
|
||||
// apostrophe (') back to '
|
||||
html = html.replace(/\'/g, ''');
|
||||
|
||||
// line breaks ' <br> ' back to '<br>'
|
||||
html = html.replace(/ <br> /g, '<br>');
|
||||
|
||||
return html;
|
||||
};
|
||||
258
apps/meteor/client/lib/emoji/helpers.ts
Normal file
258
apps/meteor/client/lib/emoji/helpers.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import { escapeRegExp } from '@rocket.chat/tools';
|
||||
import type { TranslationKey } from '@rocket.chat/ui-contexts';
|
||||
|
||||
import type { EmojiCategory, EmojiItem } from '.';
|
||||
import { emoji, emojiEmitter } from './lib';
|
||||
|
||||
export const CUSTOM_CATEGORY = 'rocket';
|
||||
export const CUSTOM_CATEGORY_CLASSNAME = 'emojipicker--custom';
|
||||
|
||||
const MIXED_TONE_SUFFIX = /_tone([1-5])-[1-5]$/;
|
||||
|
||||
type RowItem = Array<EmojiItem & { category: string }>;
|
||||
type RowDivider = { category: string; i18n: TranslationKey };
|
||||
type LoadMoreItem = { loadMore: true };
|
||||
export type EmojiPickerItem = RowItem | RowDivider | LoadMoreItem;
|
||||
|
||||
export type CategoriesIndexes = { key: string; index: number }[];
|
||||
|
||||
export const isRowDivider = (item: EmojiPickerItem): item is RowDivider => 'i18n' in item;
|
||||
export const isLoadMore = (item: EmojiPickerItem): item is LoadMoreItem => 'loadMore' in item;
|
||||
|
||||
export const createEmojiListByCategorySubscription = (
|
||||
customItemsLimit: number,
|
||||
actualTone: number,
|
||||
recentEmojis: string[],
|
||||
setRecentEmojis: (emojis: string[]) => void,
|
||||
setQuickReactions: () => void,
|
||||
): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => ReturnType<typeof createPickerEmojis>] => {
|
||||
let result: ReturnType<typeof createPickerEmojis> = [[], []];
|
||||
updateRecent(recentEmojis);
|
||||
|
||||
const sub = (cb: () => void) => {
|
||||
result = createPickerEmojis(customItemsLimit, actualTone, recentEmojis, setRecentEmojis);
|
||||
setQuickReactions();
|
||||
return emojiEmitter.on('updated', () => {
|
||||
result = createPickerEmojis(customItemsLimit, actualTone, recentEmojis, setRecentEmojis);
|
||||
setQuickReactions();
|
||||
cb();
|
||||
});
|
||||
};
|
||||
|
||||
return [sub, () => result];
|
||||
};
|
||||
|
||||
export const createPickerEmojis = (
|
||||
customItemsLimit: number,
|
||||
actualTone: number,
|
||||
recentEmojis: string[],
|
||||
setRecentEmojis: (emojis: string[]) => void,
|
||||
): [EmojiPickerItem[], CategoriesIndexes] => {
|
||||
const categories = getCategoriesList();
|
||||
const categoriesIndexes: CategoriesIndexes = [];
|
||||
|
||||
const mappedCategories = categories.reduce<EmojiPickerItem[]>((acc, category) => {
|
||||
categoriesIndexes.push({ key: category.key, index: acc.length });
|
||||
acc.push({ category: category.key, i18n: category.i18n });
|
||||
acc.push(...createEmojiList(customItemsLimit, category.key, actualTone, recentEmojis, setRecentEmojis));
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
return [mappedCategories, categoriesIndexes];
|
||||
};
|
||||
|
||||
export const createEmojiList = (
|
||||
customItemsLimit: number,
|
||||
category: string,
|
||||
actualTone: number | null,
|
||||
recentEmojis: string[],
|
||||
setRecentEmojis: (emojis: string[]) => void,
|
||||
): (RowItem | LoadMoreItem)[] => {
|
||||
const items: RowItem = [];
|
||||
const emojiPackages = Object.entries(emoji.packages);
|
||||
let count = 0;
|
||||
let limited = false;
|
||||
|
||||
emojiPackages.forEach(([packageName, emojiPackage]) => {
|
||||
if (!emojiPackage.emojisByCategory?.[category]) {
|
||||
return;
|
||||
}
|
||||
const _total = emojiPackage.emojisByCategory[category].length;
|
||||
const total = category === CUSTOM_CATEGORY ? customItemsLimit - count : _total;
|
||||
for (let i = 0; i < total; i++) {
|
||||
const current = emojiPackage.emojisByCategory[category][i];
|
||||
|
||||
const tone = actualTone && actualTone > 0 && emojiPackage.toneList.hasOwnProperty(current) ? `_tone${actualTone}` : '';
|
||||
|
||||
const emojiToRender = `:${current}${tone}:`;
|
||||
|
||||
const actualEmoji = emoji.list[emojiToRender];
|
||||
if (!actualEmoji) {
|
||||
removeFromRecent(emojiToRender, recentEmojis, setRecentEmojis);
|
||||
return;
|
||||
}
|
||||
|
||||
// A custom emoji with the same name overrides the native one (including its tones), so skip the native duplicate
|
||||
if (packageName === 'native' && emoji.list[`:${current}:`]?.emojiPackage === 'emojiCustom') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const actualPackage = actualEmoji.emojiPackage;
|
||||
const image = emoji.packages[actualPackage].renderPicker(emojiToRender);
|
||||
if (!image) {
|
||||
continue;
|
||||
}
|
||||
items.push({ emoji: current, image, category });
|
||||
count++;
|
||||
}
|
||||
|
||||
if (_total > total) {
|
||||
limited = true;
|
||||
}
|
||||
});
|
||||
|
||||
const rowCount = 9;
|
||||
const rowList: Array<RowItem | LoadMoreItem> = Array.from({ length: Math.ceil(items.length / rowCount) }).map(() => []);
|
||||
|
||||
for (let i = 0; i < rowList.length; i++) {
|
||||
const row = items.slice(i * rowCount, i * rowCount + rowCount);
|
||||
rowList[i] = row;
|
||||
}
|
||||
|
||||
if (rowList.length === 0) {
|
||||
rowList.push([]);
|
||||
}
|
||||
|
||||
if (limited) {
|
||||
rowList.push({ loadMore: true });
|
||||
}
|
||||
|
||||
return rowList;
|
||||
};
|
||||
|
||||
export const getCategoriesList = () => {
|
||||
let categoriesList: EmojiCategory[] = [];
|
||||
|
||||
for (const emojiPackage of Object.values(emoji.packages)) {
|
||||
if (emojiPackage.emojiCategories) {
|
||||
if (typeof emojiPackage.categoryIndex !== 'undefined') {
|
||||
categoriesList.splice(emojiPackage.categoryIndex, 0, ...emojiPackage.emojiCategories);
|
||||
} else {
|
||||
categoriesList = categoriesList.concat(emojiPackage.emojiCategories);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rocketPosition = categoriesList.findIndex((category) => category.key === CUSTOM_CATEGORY);
|
||||
const rocketCategory = categoriesList.splice(rocketPosition, 1)[0];
|
||||
categoriesList.push(rocketCategory);
|
||||
|
||||
return categoriesList;
|
||||
};
|
||||
|
||||
export const getEmojisBySearchTerm = (
|
||||
searchTerm: string,
|
||||
actualTone: number,
|
||||
recentEmojis: string[],
|
||||
setRecentEmojis: (emojis: string[]) => void,
|
||||
) => {
|
||||
const emojis = [];
|
||||
const seenEmojis = new Set<(typeof emoji.list)[string]>();
|
||||
const searchRegExp = new RegExp(escapeRegExp(searchTerm.replace(/:/g, '')), 'i');
|
||||
|
||||
for (let current in emoji.list) {
|
||||
if (!emoji.list.hasOwnProperty(current) || !searchRegExp.test(current)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const emojiObject = emoji.list[current];
|
||||
|
||||
// Skip duplicates (same emoji in different packages)
|
||||
if (seenEmojis.has(emojiObject)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { emojiPackage, shortnames = [], name } = emojiObject;
|
||||
let tone = '';
|
||||
current = current.replace(/:/g, '');
|
||||
|
||||
if (actualTone > 0 && emoji.packages[emojiPackage].toneList.hasOwnProperty(name ?? current)) {
|
||||
tone = `_tone${actualTone}`;
|
||||
}
|
||||
|
||||
const isNative = emojiPackage === 'native';
|
||||
if (isNative) {
|
||||
const mixedTones = current.match(MIXED_TONE_SUFFIX);
|
||||
if (mixedTones && !(actualTone > 0 && Number(mixedTones[1]) === actualTone)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const categoryName = isNative ? current.replace(MIXED_TONE_SUFFIX, '') : current;
|
||||
|
||||
const isCategoryEmoji = Object.values(emoji.packages[emojiPackage].emojisByCategory).some(
|
||||
(contents) => contents.indexOf(categoryName) !== -1,
|
||||
);
|
||||
if (!isCategoryEmoji && shortnames.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const emojiToRender = `:${current}${tone}:`;
|
||||
|
||||
const actualEmoji = emoji.list[emojiToRender];
|
||||
if (!actualEmoji) {
|
||||
removeFromRecent(emojiToRender, recentEmojis, setRecentEmojis);
|
||||
continue;
|
||||
}
|
||||
|
||||
seenEmojis.add(emojiObject);
|
||||
|
||||
const actualPackage = actualEmoji.emojiPackage;
|
||||
|
||||
emojis.push({ emoji: `${current}${tone}`, image: emoji.packages[actualPackage].renderPicker(emojiToRender) });
|
||||
}
|
||||
|
||||
return emojis;
|
||||
};
|
||||
|
||||
export const removeFromRecent = (emoji: string, recentEmojis: string[], setRecentEmojis?: (emojis: string[]) => void) => {
|
||||
const _emoji = emoji.replace(/(^:|:$)/g, '');
|
||||
const pos = recentEmojis.indexOf(_emoji);
|
||||
|
||||
if (pos === -1) {
|
||||
return;
|
||||
}
|
||||
recentEmojis.splice(pos, 1);
|
||||
setRecentEmojis?.(recentEmojis);
|
||||
};
|
||||
|
||||
// There's no need to dispatchUpdate here. This helper is called before the list is generated.
|
||||
// This means that the recent list will always be up to date by the time it is used.
|
||||
export const updateRecent = (recentList: string[]) => {
|
||||
const recentPkgList: string[] = emoji.packages.base.emojisByCategory.recent;
|
||||
recentList?.forEach((_emoji) => {
|
||||
!recentPkgList.includes(_emoji) && recentPkgList.push(_emoji);
|
||||
});
|
||||
};
|
||||
|
||||
export const replaceEmojiInRecent = ({ oldEmoji, newEmoji }: { oldEmoji: string; newEmoji: string }) => {
|
||||
const recentPkgList: string[] = emoji.packages.base.emojisByCategory.recent;
|
||||
const pos = recentPkgList.indexOf(oldEmoji);
|
||||
|
||||
if (pos !== -1) {
|
||||
recentPkgList[pos] = newEmoji;
|
||||
emoji.dispatchUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
const getEmojiRender = (emojiName: string) => {
|
||||
const emojiPackageName = emoji.list[emojiName]?.emojiPackage;
|
||||
const emojiPackage = emoji.packages[emojiPackageName];
|
||||
return emojiPackage?.render(emojiName);
|
||||
};
|
||||
|
||||
export const getFrequentEmoji = (frequentEmoji: string[]) => {
|
||||
return frequentEmoji?.map((frequentEmoji) => {
|
||||
return { emoji: frequentEmoji, image: getEmojiRender(`:${frequentEmoji}:`) };
|
||||
});
|
||||
};
|
||||
3
apps/meteor/client/lib/emoji/index.ts
Normal file
3
apps/meteor/client/lib/emoji/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './helpers';
|
||||
export type * from './types';
|
||||
export { emoji, emojiEmitter } from './lib';
|
||||
33
apps/meteor/client/lib/emoji/lib.ts
Normal file
33
apps/meteor/client/lib/emoji/lib.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Emitter } from '@rocket.chat/emitter';
|
||||
|
||||
import type { EmojiPackages } from '../../../app/emoji/lib/rocketchat';
|
||||
|
||||
export const emojiEmitter = new Emitter<{ updated: void }>();
|
||||
|
||||
export const emoji: EmojiPackages & { dispatchUpdate: () => void } = {
|
||||
packages: {
|
||||
base: {
|
||||
emojiCategories: [{ key: 'recent', i18n: 'Frequently_Used' }],
|
||||
categoryIndex: 0,
|
||||
emojisByCategory: {
|
||||
recent: [],
|
||||
},
|
||||
toneList: {},
|
||||
render: (message: string) => message,
|
||||
renderPicker(emojiToRender) {
|
||||
const correctPackage = emoji.list[emojiToRender].emojiPackage;
|
||||
if (!correctPackage) {
|
||||
return;
|
||||
}
|
||||
|
||||
return emoji.packages[correctPackage]?.renderPicker(emojiToRender);
|
||||
},
|
||||
},
|
||||
},
|
||||
list: {},
|
||||
dispatchUpdate() {
|
||||
queueMicrotask(() => {
|
||||
emojiEmitter.emit('updated');
|
||||
});
|
||||
},
|
||||
};
|
||||
20
apps/meteor/client/lib/emoji/types.ts
Normal file
20
apps/meteor/client/lib/emoji/types.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { TranslationKey } from '@rocket.chat/ui-contexts';
|
||||
|
||||
export type EmojiItem = {
|
||||
emoji: string;
|
||||
image: string;
|
||||
};
|
||||
|
||||
export type EmojiCategory = {
|
||||
key: string;
|
||||
i18n: TranslationKey;
|
||||
};
|
||||
|
||||
export type EmojiByCategory = {
|
||||
key: string;
|
||||
i18n: TranslationKey;
|
||||
emojis: {
|
||||
list: EmojiItem[];
|
||||
limit: number | null;
|
||||
};
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user