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
906 lines
26 KiB
TypeScript
906 lines
26 KiB
TypeScript
import type {
|
|
CallPreferences,
|
|
DirectCallData,
|
|
IRole,
|
|
IRoom,
|
|
ISetting,
|
|
IUser,
|
|
ProviderCapabilities,
|
|
Serialized,
|
|
SettingValue,
|
|
} from '@rocket.chat/core-typings';
|
|
import type {
|
|
ServerMethodName,
|
|
ServerMethodParameters,
|
|
ServerMethodReturn,
|
|
StreamerCallback,
|
|
StreamerCallbackArgs,
|
|
StreamerEvents,
|
|
StreamKeys,
|
|
StreamNames,
|
|
} from '@rocket.chat/ddp-client';
|
|
import { Emitter } from '@rocket.chat/emitter';
|
|
import languages from '@rocket.chat/i18n/dist/languages';
|
|
import { createPredicateFromFilter } from '@rocket.chat/mongo-adapter';
|
|
import type { Method, OperationParams, OperationResult, PathPattern, UrlParams } from '@rocket.chat/rest-typings';
|
|
import type {
|
|
Device,
|
|
DeviceContext,
|
|
LoginService,
|
|
ModalContextValue,
|
|
ServerContextValue,
|
|
SettingsContextQuery,
|
|
SubscriptionWithRoom,
|
|
ToastMessagesContextValue,
|
|
TranslationKey,
|
|
} from '@rocket.chat/ui-contexts';
|
|
import {
|
|
AuthorizationContext,
|
|
RouterContext,
|
|
ServerContext,
|
|
SettingsContext,
|
|
TranslationContext,
|
|
UserContext,
|
|
ActionManagerContext,
|
|
ModalContext,
|
|
UserPresenceContext,
|
|
AuthenticationContext,
|
|
ToastMessagesContext,
|
|
} from '@rocket.chat/ui-contexts';
|
|
import type { VideoConfPopupPayload } from '@rocket.chat/ui-video-conf';
|
|
import { VideoConfContext } from '@rocket.chat/ui-video-conf';
|
|
import type { Decorator } from '@storybook/react';
|
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
import { createInstance } from 'i18next';
|
|
import type { ObjectId } from 'mongodb';
|
|
import type { ContextType, JSXElementConstructor, ReactNode } from 'react';
|
|
import { useEffect, useReducer, useSyncExternalStore } from 'react';
|
|
import { I18nextProvider, initReactI18next } from 'react-i18next';
|
|
|
|
import { MockedDeviceContext } from './MockedDeviceContext';
|
|
|
|
type Mutable<T> = {
|
|
-readonly [P in keyof T]: T[P];
|
|
};
|
|
|
|
// interface MockedAppRootEvents extends Record<`stream-${StreamNames}-${StreamKeys<StreamNames>}`, any> {
|
|
// 'update-modal': void;
|
|
// }
|
|
// Extract all key values from objects that have a 'key' property
|
|
type ExtractKeys<T, N extends string> = T extends readonly (infer U)[]
|
|
? U extends { key: infer K }
|
|
? K extends string
|
|
? string extends K
|
|
? never
|
|
: `stream-${N}-${K}`
|
|
: never
|
|
: never
|
|
: never;
|
|
|
|
// Union of all key values from all streams
|
|
type AllStreamerEventKeys = {
|
|
[K in keyof StreamerEvents]: ExtractKeys<StreamerEvents[K], K>;
|
|
}[keyof StreamerEvents];
|
|
|
|
type MockedAppRootEvents = {
|
|
'update-modal': void;
|
|
} & Record<AllStreamerEventKeys, any>;
|
|
|
|
export type StreamControllerRef<N extends StreamNames> = {
|
|
controller?: {
|
|
emit: <K extends StreamKeys<N>>(eventName: K, args: StreamerCallbackArgs<N, K>) => void;
|
|
has: (eventName: StreamKeys<N>) => boolean;
|
|
};
|
|
};
|
|
|
|
const empty = [] as const;
|
|
|
|
export class MockedAppRootBuilder {
|
|
private _settings: Map<string, ISetting> = new Map();
|
|
|
|
private wrappers: Array<(children: ReactNode) => ReactNode> = [];
|
|
|
|
private server: ContextType<typeof ServerContext> = {
|
|
connected: true,
|
|
status: 'connected',
|
|
retryCount: 0,
|
|
info: undefined,
|
|
absoluteUrl: (path: string) => `http://localhost:3000/${path}`,
|
|
callEndpoint: <TMethod extends Method, TPathPattern extends PathPattern>({
|
|
method,
|
|
pathPattern,
|
|
}: {
|
|
method: TMethod;
|
|
pathPattern: TPathPattern;
|
|
keys: UrlParams<TPathPattern>;
|
|
params: OperationParams<TMethod, TPathPattern>;
|
|
}): Promise<Serialized<OperationResult<TMethod, TPathPattern>>> => {
|
|
throw new Error(`not implemented (method: ${method}, pathPattern: ${pathPattern})`);
|
|
},
|
|
getStream: () => () => () => undefined,
|
|
getStreamAll: () => () => () => undefined,
|
|
uploadToEndpoint: () => Promise.reject(new Error('not implemented')),
|
|
callMethod: () => Promise.reject(new Error('not implemented')),
|
|
disconnect: () => {
|
|
throw new Error('not implemented');
|
|
},
|
|
reconnect: () => {
|
|
throw new Error('not implemented');
|
|
},
|
|
writeStream: () => {
|
|
throw new Error('not implemented');
|
|
},
|
|
};
|
|
|
|
private router: ContextType<typeof RouterContext> = {
|
|
buildRoutePath: () => '/',
|
|
defineRoutes: () => () => undefined,
|
|
getLocationPathname: () => '/',
|
|
getLocationSearch: () => '',
|
|
getLocationHash: () => '',
|
|
getRouteName: () => undefined,
|
|
getPreviousRouteName: () => undefined,
|
|
getRouteParameters: () => ({}),
|
|
getSearchParameters: () => ({}),
|
|
navigate: () => undefined,
|
|
subscribeToRouteChange: () => () => undefined,
|
|
getRoomRoute: () => ({ path: '/' }),
|
|
};
|
|
|
|
private settings: Mutable<ContextType<typeof SettingsContext>> = {
|
|
hasPrivateAccess: true,
|
|
querySetting: (_id: string) => [() => () => undefined, () => undefined],
|
|
querySettings: (_query: SettingsContextQuery) => [() => () => undefined, () => empty as unknown as ISetting[]],
|
|
dispatch: async () => undefined,
|
|
};
|
|
|
|
private user: ContextType<typeof UserContext> = {
|
|
logout: () => Promise.reject(new Error('not implemented')),
|
|
onLogout: () => () => undefined,
|
|
queryPreference: () => [() => () => undefined, () => undefined],
|
|
queryRoom: () => [() => () => undefined, () => this.room],
|
|
querySubscription: () => [() => () => undefined, () => this.subscription],
|
|
querySubscriptions: () => [
|
|
() => () => undefined,
|
|
() => (this.subscription ? [this.subscription, ...(this.subscriptions ?? [])] : (this.subscriptions ?? [])),
|
|
], // apply query and option
|
|
user: null,
|
|
userId: undefined,
|
|
};
|
|
|
|
private toastMessages: ToastMessagesContextValue = {
|
|
dispatch: () => undefined,
|
|
};
|
|
|
|
private userPresence: ContextType<typeof UserPresenceContext> = {
|
|
queryUserData: (_uid) => ({ subscribe: () => () => undefined, get: () => undefined }),
|
|
};
|
|
|
|
private videoConf: ContextType<typeof VideoConfContext> = {
|
|
queryIncomingCalls: () => [() => () => undefined, () => []],
|
|
queryRinging: () => [() => () => undefined, () => false],
|
|
queryCalling: () => [() => () => undefined, () => false],
|
|
dispatchOutgoing(_options: Omit<VideoConfPopupPayload, 'id'>): void {
|
|
throw new Error('Function not implemented.');
|
|
},
|
|
dismissOutgoing(): void {
|
|
throw new Error('Function not implemented.');
|
|
},
|
|
startCall(_rid: IRoom['_id'], _title?: string): void {
|
|
throw new Error('Function not implemented.');
|
|
},
|
|
acceptCall(_callId: string): void {
|
|
throw new Error('Function not implemented.');
|
|
},
|
|
joinCall(_callId: string): void {
|
|
throw new Error('Function not implemented.');
|
|
},
|
|
dismissCall(_callId: string): void {
|
|
throw new Error('Function not implemented.');
|
|
},
|
|
rejectIncomingCall(_callId: string): void {
|
|
throw new Error('Function not implemented.');
|
|
},
|
|
abortCall(): void {
|
|
throw new Error('Function not implemented.');
|
|
},
|
|
setPreferences(_prefs: { mic?: boolean; cam?: boolean }): void {
|
|
throw new Error('Function not implemented.');
|
|
},
|
|
loadCapabilities(): Promise<void> {
|
|
throw new Error('Function not implemented.');
|
|
},
|
|
queryCapabilities(): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => ProviderCapabilities] {
|
|
throw new Error('Function not implemented.');
|
|
},
|
|
queryPreferences(): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => CallPreferences] {
|
|
throw new Error('Function not implemented.');
|
|
},
|
|
};
|
|
|
|
private room: IRoom | undefined = undefined;
|
|
|
|
private subscriptions: SubscriptionWithRoom[] | undefined = undefined;
|
|
|
|
private subscription: SubscriptionWithRoom | undefined = undefined;
|
|
|
|
private modal: ModalContextValue = {
|
|
currentModal: { component: null },
|
|
modal: {
|
|
setModal: (modal) => {
|
|
this.modal = {
|
|
...this.modal,
|
|
currentModal: { component: modal },
|
|
};
|
|
this.events.emit('update-modal');
|
|
},
|
|
},
|
|
};
|
|
|
|
// Mutated by `withRoleDefinition` before render, then held stable, so the identity is
|
|
// a safe `useSyncExternalStore` snapshot.
|
|
private rolesMap = new Map<IRole['_id'], IRole>();
|
|
|
|
private authorization: ContextType<typeof AuthorizationContext> = {
|
|
queryPermission: () => [() => () => undefined, () => false],
|
|
queryAtLeastOnePermission: () => [() => () => undefined, () => false],
|
|
queryAllPermissions: () => [() => () => undefined, () => false],
|
|
queryRole: () => [() => () => undefined, () => false],
|
|
getRoles: () => this.rolesMap,
|
|
subscribeToRoles: () => () => undefined,
|
|
};
|
|
|
|
private authServices: LoginService[] = [];
|
|
|
|
private authentication: ContextType<typeof AuthenticationContext> = {
|
|
isLoggingIn: false,
|
|
loginWithPassword: () => Promise.resolve(),
|
|
loginWithToken: () => Promise.resolve(),
|
|
loginWithService: () => () => Promise.resolve(true),
|
|
loginWithCustomOauth: () => undefined,
|
|
loginWithIframe: async () => Promise.reject('loginWithIframe not implemented'),
|
|
loginWithTokenRoute: async () => Promise.reject('loginWithTokenRoute not implemented'),
|
|
queryLoginServices: {
|
|
getCurrentValue: () => this.authServices,
|
|
subscribe: () => () => undefined,
|
|
},
|
|
getLoginToken: () => null,
|
|
unstoreLoginToken: () => () => undefined,
|
|
wipeLocalAuth: () => undefined,
|
|
};
|
|
|
|
private events = new Emitter<MockedAppRootEvents>();
|
|
|
|
private deviceContext: Partial<ContextType<typeof DeviceContext>> = {
|
|
enabled: true,
|
|
availableAudioOutputDevices: [],
|
|
availableAudioInputDevices: [],
|
|
selectedAudioOutputDevice: undefined,
|
|
selectedAudioInputDevice: undefined,
|
|
setAudioOutputDevice: () => undefined,
|
|
setAudioInputDevice: () => undefined,
|
|
permissionStatus: undefined,
|
|
};
|
|
|
|
private _providedQueryClient: QueryClient | undefined;
|
|
|
|
private get queryClient(): QueryClient {
|
|
return (
|
|
this._providedQueryClient ||
|
|
new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false },
|
|
mutations: { retry: false },
|
|
},
|
|
})
|
|
);
|
|
}
|
|
|
|
wrap(wrapper: (children: ReactNode) => ReactNode): this {
|
|
this.wrappers.push(wrapper);
|
|
return this;
|
|
}
|
|
|
|
withEndpoint<TMethod extends Method, TPathPattern extends PathPattern>(
|
|
method: TMethod,
|
|
pathPattern: TPathPattern,
|
|
response: (
|
|
params: OperationParams<TMethod, TPathPattern>,
|
|
) => Serialized<OperationResult<TMethod, TPathPattern>> | Promise<Serialized<OperationResult<TMethod, TPathPattern>>>,
|
|
): this {
|
|
const innerFn = this.server.callEndpoint;
|
|
|
|
const outerFn = <TMethod extends Method, TPathPattern extends PathPattern>(args: {
|
|
method: TMethod;
|
|
pathPattern: TPathPattern;
|
|
keys: UrlParams<TPathPattern>;
|
|
params: OperationParams<TMethod, TPathPattern>;
|
|
}): Promise<Serialized<OperationResult<TMethod, TPathPattern>>> => {
|
|
if (args.method === String(method) && args.pathPattern === String(pathPattern)) {
|
|
return Promise.resolve(response(args.params)) as Promise<Serialized<OperationResult<TMethod, TPathPattern>>>;
|
|
}
|
|
|
|
return innerFn(args);
|
|
};
|
|
|
|
this.server.callEndpoint = outerFn;
|
|
|
|
return this;
|
|
}
|
|
|
|
withStream<N extends StreamNames>(streamName: N, ref: StreamControllerRef<N>): this {
|
|
const innerFn = this.server.getStream;
|
|
|
|
const outerFn: ServerContextValue['getStream'] = (innerStreamName) => {
|
|
if (innerStreamName === (streamName as StreamNames)) {
|
|
ref.controller = {
|
|
emit: <K extends StreamKeys<N>>(eventName: K, args: StreamerCallbackArgs<N, K>) => {
|
|
this.events.emit(`stream-${innerStreamName}-${eventName}` as AllStreamerEventKeys, ...args);
|
|
},
|
|
has: (eventName: string) => this.events.has(`stream-${innerStreamName}-${eventName}` as AllStreamerEventKeys),
|
|
};
|
|
|
|
return <K extends StreamKeys<N>>(eventName: K, callback: StreamerCallback<N, K>) =>
|
|
this.events.on(`stream-${innerStreamName}-${eventName}` as AllStreamerEventKeys, callback);
|
|
}
|
|
|
|
return innerFn(innerStreamName);
|
|
};
|
|
|
|
this.server.getStream = outerFn;
|
|
|
|
return this;
|
|
}
|
|
|
|
withMethod<TMethodName extends ServerMethodName>(methodName: TMethodName, response: () => ServerMethodReturn<TMethodName>): this {
|
|
const innerFn = this.server.callMethod;
|
|
|
|
const outerFn = <TMethodName extends ServerMethodName>(
|
|
innerMethodName: TMethodName,
|
|
...innerArgs: ServerMethodParameters<TMethodName>
|
|
): Promise<ServerMethodReturn<TMethodName>> => {
|
|
if (innerMethodName === String(methodName)) {
|
|
return Promise.resolve(response()) as Promise<ServerMethodReturn<TMethodName>>;
|
|
}
|
|
|
|
if (!innerFn) {
|
|
throw new Error('not implemented');
|
|
}
|
|
|
|
return innerFn(innerMethodName, ...innerArgs);
|
|
};
|
|
|
|
this.server.callMethod = outerFn;
|
|
|
|
return this;
|
|
}
|
|
|
|
withPermission(permission: string): this {
|
|
const innerFn = this.authorization.queryPermission;
|
|
|
|
const outerFn = (
|
|
innerPermission: string | ObjectId,
|
|
innerScope?: string | ObjectId | undefined,
|
|
innerScopedRoles?: string[] | undefined,
|
|
): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => boolean] => {
|
|
if (innerPermission === permission) {
|
|
return [() => () => undefined, () => true];
|
|
}
|
|
|
|
return innerFn(innerPermission, innerScope, innerScopedRoles);
|
|
};
|
|
|
|
this.authorization.queryPermission = outerFn;
|
|
|
|
const innerFn2 = this.authorization.queryAtLeastOnePermission;
|
|
|
|
const outerFn2 = (
|
|
innerPermissions: Array<string | ObjectId>,
|
|
innerScope?: string | ObjectId | undefined,
|
|
innerScopedRoles?: string[] | undefined,
|
|
): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => boolean] => {
|
|
if (innerPermissions.includes(permission)) {
|
|
return [() => () => undefined, () => true];
|
|
}
|
|
|
|
return innerFn2(innerPermissions, innerScope, innerScopedRoles);
|
|
};
|
|
|
|
this.authorization.queryAtLeastOnePermission = outerFn2;
|
|
|
|
const innerFn3 = this.authorization.queryAllPermissions;
|
|
|
|
const outerFn3 = (
|
|
innerPermissions: Array<string | ObjectId>,
|
|
innerScope?: string | ObjectId | undefined,
|
|
innerScopedRoles?: string[] | undefined,
|
|
): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => boolean] => {
|
|
if (innerPermissions.includes(permission)) {
|
|
return [() => () => undefined, () => true];
|
|
}
|
|
|
|
return innerFn3(innerPermissions, innerScope, innerScopedRoles);
|
|
};
|
|
|
|
this.authorization.queryAllPermissions = outerFn3;
|
|
|
|
return this;
|
|
}
|
|
|
|
withJohnDoe(overrides: Partial<IUser> = {}): this {
|
|
this.user.userId = 'john.doe';
|
|
|
|
this.user.user = {
|
|
_id: 'john.doe',
|
|
username: 'john.doe',
|
|
name: 'John Doe',
|
|
createdAt: new Date(),
|
|
active: true,
|
|
_updatedAt: new Date(),
|
|
roles: ['admin'],
|
|
type: 'user',
|
|
...overrides,
|
|
};
|
|
|
|
return this;
|
|
}
|
|
|
|
withAnonymous(): this {
|
|
this.user.userId = undefined;
|
|
this.user.user = null;
|
|
|
|
return this;
|
|
}
|
|
|
|
withUser(user: IUser): this {
|
|
this.user.userId = user._id;
|
|
this.user.user = user;
|
|
|
|
return this;
|
|
}
|
|
|
|
withLogout(logout: ContextType<typeof UserContext>['logout']): this {
|
|
this.user = { ...this.user, logout };
|
|
|
|
return this;
|
|
}
|
|
|
|
withToastMessageDispatch(dispatch: ToastMessagesContextValue['dispatch']): this {
|
|
this.toastMessages = { ...this.toastMessages, dispatch };
|
|
|
|
return this;
|
|
}
|
|
|
|
withUsers(users: IUser[]): this {
|
|
users.forEach((user) => {
|
|
this.userPresence.queryUserData = (_uid) => ({ subscribe: () => () => undefined, get: () => user });
|
|
});
|
|
|
|
return this;
|
|
}
|
|
|
|
withSubscriptions(subscriptions: SubscriptionWithRoom[]): this {
|
|
this.subscriptions = subscriptions;
|
|
|
|
return this;
|
|
}
|
|
|
|
withSubscription(subscription: SubscriptionWithRoom): this {
|
|
this.subscription = subscription;
|
|
|
|
return this;
|
|
}
|
|
|
|
withRoom(room: IRoom): this {
|
|
this.room = room;
|
|
|
|
return this;
|
|
}
|
|
|
|
withRouter(overrides: Partial<ContextType<typeof RouterContext>>): this {
|
|
this.router = { ...this.router, ...overrides };
|
|
return this;
|
|
}
|
|
|
|
withRouteParameter(name: string, value: string): this {
|
|
const innerFn = this.router.getRouteParameters;
|
|
this.router.getRouteParameters = () => ({ ...innerFn(), [name]: value });
|
|
return this;
|
|
}
|
|
|
|
withRole(role: string): this {
|
|
if (!this.user.user) {
|
|
throw new Error('user is not defined');
|
|
}
|
|
|
|
this.user.user.roles.push(role);
|
|
|
|
const innerFn = this.authorization.queryRole;
|
|
|
|
const outerFn = (
|
|
innerRole: string | ObjectId,
|
|
innerScope?: string | undefined,
|
|
): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => boolean] => {
|
|
if (innerRole === role) {
|
|
return [() => () => undefined, () => true];
|
|
}
|
|
|
|
return innerFn(innerRole, innerScope);
|
|
};
|
|
|
|
this.authorization.queryRole = outerFn;
|
|
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Grants a role scoped to `Subscriptions` — `owner`, `moderator`, `leader`, or a custom
|
|
* one — in a single room. Unlike {@link withRole}, the grant is not workspace-wide: a
|
|
* check only passes when it carries that room as its scope, which is how the real
|
|
* provider resolves a subscription role. The role is deliberately kept out of the user's
|
|
* `roles`, where only a `Users`-scoped grant belongs.
|
|
*/
|
|
withRoleScoped(role: string, scope: IRoom['_id']): this {
|
|
const innerFn = this.authorization.queryRole;
|
|
|
|
const outerFn = (
|
|
innerRole: string | ObjectId,
|
|
innerScope?: string | undefined,
|
|
): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => boolean] => {
|
|
if (innerRole === role && innerScope === scope) {
|
|
return [() => () => undefined, () => true];
|
|
}
|
|
|
|
return innerFn(innerRole, innerScope);
|
|
};
|
|
|
|
this.authorization.queryRole = outerFn;
|
|
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Registers a role in the workspace roles map without granting it. A custom role
|
|
* has an id that differs from its name, so pass both to exercise code that
|
|
* resolves a name to an id. Chain `withRole(_id)` to grant it to the user.
|
|
*/
|
|
withRoleDefinition(role: Pick<IRole, '_id' | 'name'> & Partial<IRole>): this {
|
|
this.rolesMap.set(role._id, {
|
|
description: '',
|
|
protected: false,
|
|
scope: 'Users',
|
|
...role,
|
|
} as IRole);
|
|
|
|
return this;
|
|
}
|
|
|
|
withSetting(id: string, value: SettingValue, settingStructure?: Partial<ISetting>): this {
|
|
const setting = {
|
|
...settingStructure,
|
|
_id: id,
|
|
value,
|
|
} as ISetting;
|
|
|
|
const innerFn = this.settings.querySetting;
|
|
const outerFn = (
|
|
innerSetting: string,
|
|
): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => ISetting | undefined] => {
|
|
if (innerSetting === id) {
|
|
return [() => () => undefined, () => setting];
|
|
}
|
|
|
|
return innerFn(innerSetting);
|
|
};
|
|
|
|
this.settings.querySetting = outerFn;
|
|
|
|
this._settings.set(id, setting);
|
|
|
|
const cache = new WeakMap();
|
|
|
|
this.settings.querySettings = (query: SettingsContextQuery) => {
|
|
const filter =
|
|
cache.get(query) ??
|
|
createPredicateFromFilter({
|
|
...(query._id ? { _id: { $in: query._id } } : {}),
|
|
} as any);
|
|
cache.set(query, filter);
|
|
const arr = cache.get(filter) ?? Array.from(this._settings.values()).filter(filter);
|
|
return [
|
|
() => () => undefined,
|
|
() => {
|
|
return arr;
|
|
},
|
|
];
|
|
};
|
|
|
|
return this;
|
|
}
|
|
|
|
withUserPreference(id: string | ObjectId, value: unknown): this {
|
|
const innerFn = this.user.queryPreference;
|
|
|
|
const outerFn = <T,>(
|
|
key: string | ObjectId,
|
|
defaultValue?: T | undefined,
|
|
): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => T | undefined] => {
|
|
if (key === id) {
|
|
return [() => () => undefined, () => value as T];
|
|
}
|
|
|
|
return innerFn(key, defaultValue);
|
|
};
|
|
|
|
this.user.queryPreference = outerFn;
|
|
|
|
return this;
|
|
}
|
|
|
|
withIncomingCalls(calls: DirectCallData[]): this {
|
|
if (!this.videoConf) {
|
|
throw Error('videoConf is not defined');
|
|
}
|
|
|
|
const innerFn = this.videoConf.queryIncomingCalls;
|
|
|
|
const outerFn = (): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => DirectCallData[]] => {
|
|
if (calls.length) {
|
|
return [() => () => undefined, () => calls];
|
|
}
|
|
|
|
return innerFn();
|
|
};
|
|
|
|
this.videoConf.queryIncomingCalls = outerFn;
|
|
|
|
return this;
|
|
}
|
|
|
|
withOpenModal(modal: ReactNode) {
|
|
this.modal.currentModal = { component: modal };
|
|
|
|
return this;
|
|
}
|
|
|
|
withAudioInputDevices(devices: Device[]): this {
|
|
if (!this.deviceContext.enabled) {
|
|
throw new Error('DeviceContext is not enabled');
|
|
}
|
|
|
|
this.deviceContext.availableAudioInputDevices = devices;
|
|
return this;
|
|
}
|
|
|
|
withAudioOutputDevices(devices: Device[]): this {
|
|
if (!this.deviceContext.enabled) {
|
|
throw new Error('DeviceContext is not enabled');
|
|
}
|
|
|
|
this.deviceContext.availableAudioOutputDevices = devices;
|
|
return this;
|
|
}
|
|
|
|
withMicrophonePermissionState(status: PermissionStatus): this {
|
|
if (!this.deviceContext.enabled) {
|
|
throw new Error('DeviceContext is not enabled');
|
|
}
|
|
|
|
this.deviceContext.permissionStatus = status;
|
|
return this;
|
|
}
|
|
|
|
private i18n = createInstance({
|
|
// debug: true,
|
|
lng: 'en',
|
|
fallbackLng: 'en',
|
|
ns: ['core'],
|
|
nsSeparator: '.',
|
|
partialBundledLanguages: true,
|
|
defaultNS: 'core',
|
|
interpolation: {
|
|
escapeValue: false,
|
|
},
|
|
initImmediate: false,
|
|
}).use(initReactI18next);
|
|
|
|
withTranslations(lng: string, ns: string, resources: Record<string, string>): this {
|
|
const addResources = () => {
|
|
this.i18n.addResources(lng, ns, resources);
|
|
for (const [key, value] of Object.entries(resources)) {
|
|
this.i18n.addResource(lng, ns, key, value);
|
|
}
|
|
};
|
|
|
|
if (this.i18n.isInitialized) {
|
|
addResources();
|
|
return this;
|
|
}
|
|
|
|
this.i18n.on('initialized', addResources);
|
|
return this;
|
|
}
|
|
|
|
// Manually changes the language in the i18next instance
|
|
// To be used with languages other than the default one
|
|
withDefaultLanguage(lng: string): this {
|
|
if (this.i18n.isInitialized) {
|
|
void this.i18n.changeLanguage(lng);
|
|
return this;
|
|
}
|
|
|
|
this.i18n.on('initialized', () => {
|
|
void this.i18n.changeLanguage(lng);
|
|
});
|
|
|
|
return this;
|
|
}
|
|
|
|
withServerContext(partial: Partial<ServerContextValue>): this {
|
|
this.server = { ...this.server, ...partial };
|
|
return this;
|
|
}
|
|
|
|
withQueryClient(client: QueryClient): this {
|
|
this._providedQueryClient = client;
|
|
return this;
|
|
}
|
|
|
|
build(): JSXElementConstructor<{ children: ReactNode }> {
|
|
const {
|
|
queryClient,
|
|
server,
|
|
router,
|
|
settings,
|
|
user,
|
|
userPresence,
|
|
videoConf,
|
|
i18n,
|
|
authorization,
|
|
wrappers,
|
|
deviceContext,
|
|
authentication,
|
|
toastMessages,
|
|
} = this;
|
|
|
|
const reduceTranslation = (translation?: ContextType<typeof TranslationContext>): ContextType<typeof TranslationContext> => {
|
|
return {
|
|
...translation,
|
|
language: i18n.isInitialized ? i18n.language : 'en',
|
|
languages: [
|
|
{
|
|
en: 'Default',
|
|
name: i18n.isInitialized ? i18n.t('Default') : 'Default',
|
|
ogName: i18n.isInitialized ? i18n.t('Default') : 'Default',
|
|
key: '',
|
|
},
|
|
...(i18n.isInitialized
|
|
? [...new Set([...i18n.languages, ...languages])].map((key) => ({
|
|
en: key,
|
|
name: new Intl.DisplayNames([key], { type: 'language' }).of(key) ?? key,
|
|
ogName: new Intl.DisplayNames([key], { type: 'language' }).of(key) ?? key,
|
|
key,
|
|
}))
|
|
: []),
|
|
],
|
|
loadLanguage: async (language) => {
|
|
if (!i18n.isInitialized) {
|
|
return;
|
|
}
|
|
|
|
await i18n.changeLanguage(language);
|
|
},
|
|
translate: Object.assign(
|
|
(key: TranslationKey, options?: unknown) => (i18n.isInitialized ? i18n.t(key, options as { lng?: string }) : ''),
|
|
{
|
|
has: (key: string, options?: { lng?: string }): key is TranslationKey =>
|
|
!!key && i18n.isInitialized && i18n.exists(key, options),
|
|
},
|
|
),
|
|
};
|
|
};
|
|
|
|
const subscribeToModal = (onStoreChange: () => void) => this.events.on('update-modal', onStoreChange);
|
|
|
|
const getModalSnapshot = () => this.modal;
|
|
|
|
void i18n.init();
|
|
|
|
return function MockedAppRoot({ children }) {
|
|
const [translation, updateTranslation] = useReducer(reduceTranslation, undefined, () => reduceTranslation());
|
|
|
|
useEffect(() => {
|
|
i18n.on('initialized', updateTranslation);
|
|
i18n.on('languageChanged', updateTranslation);
|
|
|
|
return () => {
|
|
i18n.off('initialized', updateTranslation);
|
|
i18n.off('languageChanged', updateTranslation);
|
|
};
|
|
}, []);
|
|
|
|
const modal = useSyncExternalStore(subscribeToModal, getModalSnapshot);
|
|
|
|
return (
|
|
<QueryClientProvider client={queryClient}>
|
|
<ServerContext.Provider value={server}>
|
|
<RouterContext.Provider value={router}>
|
|
<SettingsContext.Provider value={settings}>
|
|
<I18nextProvider i18n={i18n}>
|
|
<TranslationContext.Provider value={translation}>
|
|
{/* <SessionProvider>
|
|
<TooltipProvider> */}
|
|
<ToastMessagesContext.Provider value={toastMessages}>
|
|
{/* <LayoutProvider>
|
|
<AvatarUrlProvider>
|
|
<CustomSoundProvider> */}
|
|
<UserContext.Provider value={user}>
|
|
<AuthenticationContext.Provider value={authentication}>
|
|
<MockedDeviceContext {...deviceContext}>
|
|
<ModalContext.Provider value={modal}>
|
|
<AuthorizationContext.Provider value={authorization}>
|
|
{/* <EmojiPickerProvider>
|
|
<OmnichannelRoomIconProvider>
|
|
*/}
|
|
<UserPresenceContext.Provider value={userPresence}>
|
|
<ActionManagerContext.Provider
|
|
value={{
|
|
generateTriggerId: () => '',
|
|
emitInteraction: () => Promise.reject(new Error('not implemented')),
|
|
getInteractionPayloadByViewId: () => undefined,
|
|
handleServerInteraction: () => undefined,
|
|
off: () => undefined,
|
|
on: () => undefined,
|
|
openView: () => undefined,
|
|
disposeView: () => undefined,
|
|
notifyBusy: () => undefined,
|
|
notifyIdle: () => undefined,
|
|
}}
|
|
>
|
|
<VideoConfContext.Provider value={videoConf}>
|
|
{/* <CallProvider>
|
|
<OmnichannelProvider> */}
|
|
{wrappers.reduce<ReactNode>(
|
|
(children, wrapper) => wrapper(children),
|
|
<>
|
|
{children}
|
|
{modal.currentModal.component}
|
|
</>,
|
|
)}
|
|
{/* </OmnichannelProvider>
|
|
</CallProvider> */}
|
|
</VideoConfContext.Provider>
|
|
</ActionManagerContext.Provider>
|
|
</UserPresenceContext.Provider>
|
|
{/*
|
|
</OmnichannelRoomIconProvider>
|
|
</EmojiPickerProvider>*/}
|
|
</AuthorizationContext.Provider>
|
|
</ModalContext.Provider>
|
|
</MockedDeviceContext>
|
|
</AuthenticationContext.Provider>
|
|
</UserContext.Provider>
|
|
{/* </CustomSoundProvider>
|
|
</AvatarUrlProvider>
|
|
</LayoutProvider> */}
|
|
</ToastMessagesContext.Provider>
|
|
{/* </TooltipProvider>
|
|
</SessionProvider> */}
|
|
</TranslationContext.Provider>
|
|
</I18nextProvider>
|
|
</SettingsContext.Provider>
|
|
</RouterContext.Provider>
|
|
</ServerContext.Provider>
|
|
</QueryClientProvider>
|
|
);
|
|
};
|
|
}
|
|
|
|
buildStoryDecorator(): Decorator {
|
|
const WrapperComponent = this.build();
|
|
|
|
// eslint-disable-next-line react/display-name, react/no-multi-comp
|
|
return (fn) => <WrapperComponent>{fn()}</WrapperComponent>;
|
|
}
|
|
}
|