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 = { -readonly [P in keyof T]: T[P]; }; // interface MockedAppRootEvents extends Record<`stream-${StreamNames}-${StreamKeys}`, any> { // 'update-modal': void; // } // Extract all key values from objects that have a 'key' property type ExtractKeys = 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; }[keyof StreamerEvents]; type MockedAppRootEvents = { 'update-modal': void; } & Record; export type StreamControllerRef = { controller?: { emit: >(eventName: K, args: StreamerCallbackArgs) => void; has: (eventName: StreamKeys) => boolean; }; }; const empty = [] as const; export class MockedAppRootBuilder { private _settings: Map = new Map(); private wrappers: Array<(children: ReactNode) => ReactNode> = []; private server: ContextType = { connected: true, status: 'connected', retryCount: 0, info: undefined, absoluteUrl: (path: string) => `http://localhost:3000/${path}`, callEndpoint: ({ method, pathPattern, }: { method: TMethod; pathPattern: TPathPattern; keys: UrlParams; params: OperationParams; }): Promise>> => { 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 = { buildRoutePath: () => '/', defineRoutes: () => () => undefined, getLocationPathname: () => '/', getLocationSearch: () => '', getLocationHash: () => '', getRouteName: () => undefined, getPreviousRouteName: () => undefined, getRouteParameters: () => ({}), getSearchParameters: () => ({}), navigate: () => undefined, subscribeToRouteChange: () => () => undefined, getRoomRoute: () => ({ path: '/' }), }; private settings: Mutable> = { hasPrivateAccess: true, querySetting: (_id: string) => [() => () => undefined, () => undefined], querySettings: (_query: SettingsContextQuery) => [() => () => undefined, () => empty as unknown as ISetting[]], dispatch: async () => undefined, }; private user: ContextType = { 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 = { queryUserData: (_uid) => ({ subscribe: () => () => undefined, get: () => undefined }), }; private videoConf: ContextType = { queryIncomingCalls: () => [() => () => undefined, () => []], queryRinging: () => [() => () => undefined, () => false], queryCalling: () => [() => () => undefined, () => false], dispatchOutgoing(_options: Omit): 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 { 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(); private authorization: ContextType = { queryPermission: () => [() => () => undefined, () => false], queryAtLeastOnePermission: () => [() => () => undefined, () => false], queryAllPermissions: () => [() => () => undefined, () => false], queryRole: () => [() => () => undefined, () => false], getRoles: () => this.rolesMap, subscribeToRoles: () => () => undefined, }; private authServices: LoginService[] = []; private authentication: ContextType = { 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(); private deviceContext: Partial> = { 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( method: TMethod, pathPattern: TPathPattern, response: ( params: OperationParams, ) => Serialized> | Promise>>, ): this { const innerFn = this.server.callEndpoint; const outerFn = (args: { method: TMethod; pathPattern: TPathPattern; keys: UrlParams; params: OperationParams; }): Promise>> => { if (args.method === String(method) && args.pathPattern === String(pathPattern)) { return Promise.resolve(response(args.params)) as Promise>>; } return innerFn(args); }; this.server.callEndpoint = outerFn; return this; } withStream(streamName: N, ref: StreamControllerRef): this { const innerFn = this.server.getStream; const outerFn: ServerContextValue['getStream'] = (innerStreamName) => { if (innerStreamName === (streamName as StreamNames)) { ref.controller = { emit: >(eventName: K, args: StreamerCallbackArgs) => { this.events.emit(`stream-${innerStreamName}-${eventName}` as AllStreamerEventKeys, ...args); }, has: (eventName: string) => this.events.has(`stream-${innerStreamName}-${eventName}` as AllStreamerEventKeys), }; return >(eventName: K, callback: StreamerCallback) => this.events.on(`stream-${innerStreamName}-${eventName}` as AllStreamerEventKeys, callback); } return innerFn(innerStreamName); }; this.server.getStream = outerFn; return this; } withMethod(methodName: TMethodName, response: () => ServerMethodReturn): this { const innerFn = this.server.callMethod; const outerFn = ( innerMethodName: TMethodName, ...innerArgs: ServerMethodParameters ): Promise> => { if (innerMethodName === String(methodName)) { return Promise.resolve(response()) as Promise>; } 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, 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, 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 = {}): 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['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>): 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 & Partial): this { this.rolesMap.set(role._id, { description: '', protected: false, scope: 'Users', ...role, } as IRole); return this; } withSetting(id: string, value: SettingValue, settingStructure?: Partial): 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 = ( 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): 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): 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): ContextType => { 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 ( {/* */} {/* */} {/* */} '', emitInteraction: () => Promise.reject(new Error('not implemented')), getInteractionPayloadByViewId: () => undefined, handleServerInteraction: () => undefined, off: () => undefined, on: () => undefined, openView: () => undefined, disposeView: () => undefined, notifyBusy: () => undefined, notifyIdle: () => undefined, }} > {/* */} {wrappers.reduce( (children, wrapper) => wrapper(children), <> {children} {modal.currentModal.component} , )} {/* */} {/* */} {/* */} {/* */} ); }; } buildStoryDecorator(): Decorator { const WrapperComponent = this.build(); // eslint-disable-next-line react/display-name, react/no-multi-comp return (fn) => {fn()}; } }