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

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

View File

@@ -0,0 +1,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();
}
},
});
});

View 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 }),
},
});
});
};

View 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;
};

View 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';

View 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();

View 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);
}
}

View 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,
});
}
};
}

View 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();
}
}

View 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();
}
}
}

View 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();
}
}
}

View 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,
};

View 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;
};

View 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;
}
});

View 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)),
);

View 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);
};

View 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);

View 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);
}
})();

View 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);
}
})();

View 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);
});
});

View 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;
}

View 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);
},
};

View 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();

View File

@@ -0,0 +1,3 @@
import { liveAuthorizationFunctions } from './liveAuthorizationFunctions';
export const { hasAllPermission, hasAtLeastOnePermission, hasPermission, userHasAllPermission } = liveAuthorizationFunctions;

View File

@@ -0,0 +1,3 @@
import { liveAuthorizationFunctions } from './liveAuthorizationFunctions';
export const { hasRole } = liveAuthorizationFunctions;

View File

@@ -0,0 +1,2 @@
export { hasAllPermission, hasAtLeastOnePermission, hasPermission, userHasAllPermission } from './hasPermission';
export { hasRole } from './hasRole';

View File

@@ -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);

View 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();
});
});
});

View 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,
);
}
}
};
};

View File

@@ -0,0 +1 @@
export { AutoTranslate, createAutoTranslateMessageStreamHandler } from './autotranslate';

View 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');
};

View 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;
})();

View 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()}`;
};

View 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();
});
}
}

View 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,
};

View 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;
},
}));

View 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();
};

View 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,
);

View 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') },
]);
});
});
});

View 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));
},
};
}

View 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>;
};
};

View 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;

View 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,
};
};

View File

@@ -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 });
};

View 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;
};

View 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();
},
},
});
});
};

View 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;
};

View 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;
};

View 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;
};

View 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');
});
});

View 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,
},
);
};

View 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,
},
});
});
};

View 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;
};

View 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();
}
};

View 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 });
});
}
}

View 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 });

View 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;
}

View 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';

View 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

View 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);
});
});

View 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');
}
};

View 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 }),
};
};

View 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?.()];
};
};

View 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,
};
};

View 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;
};

View 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);
}
}
}

View 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);
}
}

View 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();
});
});

View 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`,
);
};

View File

@@ -0,0 +1 @@
export type E2EEState = 'NOT_STARTED' | 'DISABLED' | 'LOADING_KEYS' | 'READY' | 'SAVE_PASSWORD' | 'ENTER_PASSWORD' | 'ERROR';

View File

@@ -0,0 +1,2 @@
export type E2ERoomState =
'NOT_STARTED' | 'DISABLED' | 'ESTABLISHING' | 'CREATING_KEYS' | 'WAITING_KEYS' | 'KEYS_RECEIVED' | 'READY' | 'ERROR';

View 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"`);
});
});
});

View 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;
},
};

View File

@@ -0,0 +1,4 @@
export interface ICodec<TIn, TOut, TEnc = TIn> {
decode: (data: TIn) => TOut;
encode: (data: TOut) => TEnc;
}

View 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"}"`);
});
});

View 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}`);
}
};

View 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');
});
});

View 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) };
};

View 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']);
});
});

View 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) };
};

View 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']);
});
});

View 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);
};

View 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');
});
});
});

View 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>;
}

View 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('');
}

View File

@@ -0,0 +1 @@
export * from './rocketchat.e2e';

View 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));
});
});

View 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 } });
}
}

View 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);
};

View 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);
});
});

View 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;
},
};

View 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
});
});

View 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;
}
}

View 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');
});
});

View 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();
});

File diff suppressed because it is too large Load Diff

View 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 `&lt;`).
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();
// &#39; to apostrophe (') for emojis such as :')
html = html.replace(/&#39;/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 &#39;
html = html.replace(/\'/g, '&#39;');
// line breaks ' <br> ' back to '<br>'
html = html.replace(/ <br> /g, '<br>');
return html;
};

View 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}:`) };
});
};

View File

@@ -0,0 +1,3 @@
export * from './helpers';
export type * from './types';
export { emoji, emojiEmitter } from './lib';

View 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');
});
},
};

View 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