diff --git a/.eslintrc b/.eslintrc
index 1ff16d91..99655a22 100644
--- a/.eslintrc
+++ b/.eslintrc
@@ -73,7 +73,26 @@
]
}
],
- "tailwindcss/classnames-order": ["error"]
+ "tailwindcss/classnames-order": ["error"],
+ "no-restricted-exports": "off", // This rule goes against sonar cloud recommendations
+ "no-restricted-properties": [
+ "warn",
+ {
+ "object": "it",
+ "property": "only",
+ "message": "Remove .only from tests before committing!"
+ },
+ {
+ "object": "describe",
+ "property": "only",
+ "message": "Remove .only from tests before committing!"
+ },
+ {
+ "object": "test",
+ "property": "only",
+ "message": "Remove .only from tests before committing!"
+ }
+ ]
},
"settings": {
"tailwindcss": {
diff --git a/.gitignore b/.gitignore
index 54641707..2a6daa14 100644
--- a/.gitignore
+++ b/.gitignore
@@ -51,3 +51,4 @@ build
/scripts/allLicenseResults.json
vcr.yml
+.scannerwork/
diff --git a/frontend/package.json b/frontend/package.json
index 7838a788..0989aa9f 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -37,15 +37,18 @@
"autolinker": "^4.0.0",
"autoprefixer": "^10.4.19",
"axios": "^1.12.0",
+ "classnames": "^2.5.1",
"events": "^3.3.0",
"i18next": "^25.3.2",
"i18next-browser-languagedetector": "^8.2.0",
+ "json-storage-formatter": "^2.0.9",
"lodash": "^4.17.21",
"opentok-layout-js": "^5.4.0",
"opentok-solutions-logging": "^1.1.5",
"postcss": "^8.4.38",
"react": "^19.1.0",
"react-dom": "^19.1.0",
+ "react-global-state-hooks": "^14.0.2",
"react-i18next": "^15.6.1",
"react-router-dom": "^6.11.0",
"resize-observer-polyfill": "^1.5.1",
@@ -54,8 +57,8 @@
"ua-parser-js": "^1.0.37",
"uuid": "^9.0.1",
"vite": "^5.4.19",
- "vitest": "^1.6",
- "vite-plugin-checker": "^0.11.0"
+ "vite-plugin-checker": "^0.11.0",
+ "vitest": "^1.6"
},
"devDependencies": {
"@types/lodash": "^4.17.1",
diff --git a/frontend/src/App.spec.tsx b/frontend/src/App.spec.tsx
index 8770b5d2..d87edc22 100644
--- a/frontend/src/App.spec.tsx
+++ b/frontend/src/App.spec.tsx
@@ -10,31 +10,9 @@ vi.mock('./pages/UnsupportedBrowserPage', () => ({
default: () =>
Unsupported Browser
,
}));
-// Mock context providers and wrappers
-vi.mock('./Context/PreviewPublisherProvider', () => ({
- __esModule: true,
- PreviewPublisherProvider: ({ children }: PropsWithChildren) => children,
- default: ({ children }: PropsWithChildren) => children,
-}));
-vi.mock('./Context/PublisherProvider', () => ({
- __esModule: true,
- PublisherProvider: ({ children }: PropsWithChildren) => children,
- default: ({ children }: PropsWithChildren) => children,
-}));
-vi.mock('./Context/SessionProvider/session', () => ({
- default: ({ children }: PropsWithChildren) => children,
-}));
vi.mock('./components/RedirectToWaitingRoom', () => ({
default: ({ children }: PropsWithChildren) => children,
}));
-vi.mock('./Context/RoomContext', () => ({
- default: ({ children }: PropsWithChildren) => children,
-}));
-vi.mock('./Context/ConfigProvider', () => ({
- __esModule: true,
- ConfigContextProvider: ({ children }: PropsWithChildren) => children,
- default: ({ children }: PropsWithChildren) => children,
-}));
describe('App routing', () => {
it('renders LandingPage on unknown route', () => {
diff --git a/frontend/src/Context/AppConfig/AppConfigContext.spec.tsx b/frontend/src/Context/AppConfig/AppConfigContext.spec.tsx
new file mode 100644
index 00000000..82cb0355
--- /dev/null
+++ b/frontend/src/Context/AppConfig/AppConfigContext.spec.tsx
@@ -0,0 +1,133 @@
+import { describe, expect, it, vi } from 'vitest';
+import { act, renderHook as renderHookBase, waitFor } from '@testing-library/react';
+import defaultAppConfig from './helpers/defaultAppConfig';
+import appConfigStore from './AppConfigContext';
+import type { AppConfig } from './AppConfigContext.types';
+
+describe('AppConfigContext', () => {
+ it('returns the default config when no config.json is loaded', async () => {
+ const { result } = renderHook(() => appConfigStore.use()[0]);
+
+ await waitFor(() => {
+ expect(result.current).toEqual(expect.objectContaining(defaultAppConfig));
+ });
+ });
+
+ it('merges config.json values if loaded (mocked fetch)', async () => {
+ expect.assertions(2);
+
+ // All values in this config should override the defaultConfig
+ const mockConfig: AppConfig = {
+ isAppConfigLoaded: true,
+ videoSettings: {
+ allowCameraControl: false,
+ defaultResolution: '640x480',
+ allowVideoOnJoin: false,
+ allowBackgroundEffects: false,
+ },
+ audioSettings: {
+ allowAdvancedNoiseSuppression: false,
+ allowAudioOnJoin: false,
+ allowMicrophoneControl: false,
+ },
+ waitingRoomSettings: {
+ allowDeviceSelection: false,
+ },
+ meetingRoomSettings: {
+ allowArchiving: false,
+ allowCaptions: false,
+ allowChat: false,
+ allowDeviceSelection: false,
+ allowEmojis: false,
+ allowScreenShare: false,
+ defaultLayoutMode: 'grid',
+ showParticipantList: false,
+ },
+ };
+
+ vi.spyOn(global, 'fetch').mockResolvedValue({
+ json: async () => mockConfig,
+ headers: {
+ get: () => 'application/json',
+ },
+ } as unknown as Response);
+
+ const { result } = renderHook(() => appConfigStore.use());
+ let [appConfig, { loadAppConfig }] = result.current;
+
+ expect(appConfig).not.toEqual(mockConfig);
+
+ await loadAppConfig();
+
+ [appConfig, { loadAppConfig }] = result.current;
+
+ expect(appConfig).toEqual({
+ ...mockConfig,
+ isAppConfigLoaded: true,
+ });
+ });
+
+ it('falls back to defaultConfig if fetch fails', async () => {
+ expect.assertions(4);
+
+ const mockFetchError = new Error('mocking a failure to fetch');
+
+ vi.spyOn(global, 'fetch').mockRejectedValue(mockFetchError as unknown as Response);
+
+ const { result, rerender } = renderHook(() => appConfigStore.use());
+ let [appConfig, { loadAppConfig }] = result.current;
+
+ expect(appConfig.isAppConfigLoaded).toBe(false);
+ expect(loadAppConfig()).rejects.toThrow('mocking a failure to fetch');
+
+ await act(() => {
+ rerender();
+ });
+
+ [appConfig, { loadAppConfig }] = result.current;
+
+ expect(appConfig.isAppConfigLoaded).toBe(true);
+
+ expect(appConfig).toEqual({
+ ...defaultAppConfig,
+ isAppConfigLoaded: true,
+ });
+ });
+
+ it('falls back to defaultConfig if no config.json is found', async () => {
+ expect.assertions(4);
+
+ vi.spyOn(global, 'fetch').mockResolvedValue({
+ ok: false,
+ status: 404,
+ statusText: 'Not Found',
+ headers: {
+ get: () => 'text/html',
+ },
+ } as unknown as Response);
+
+ const { result, rerender } = renderHook(() => appConfigStore.use());
+ let [appConfig, { loadAppConfig }] = result.current;
+
+ expect(appConfig).toEqual(defaultAppConfig);
+
+ expect(loadAppConfig()).rejects.toThrow('No valid JSON found, using default config');
+
+ await act(() => {
+ rerender();
+ });
+
+ [appConfig, { loadAppConfig }] = result.current;
+
+ expect(appConfig.isAppConfigLoaded).toBe(true);
+
+ expect(appConfig).toEqual({
+ ...defaultAppConfig,
+ isAppConfigLoaded: true,
+ });
+ });
+});
+
+function renderHook(render: (initialProps: Props) => Result) {
+ return renderHookBase(render, { wrapper: appConfigStore.Provider });
+}
diff --git a/frontend/src/Context/AppConfig/AppConfigContext.ts b/frontend/src/Context/AppConfig/AppConfigContext.ts
new file mode 100644
index 00000000..a92f51ba
--- /dev/null
+++ b/frontend/src/Context/AppConfig/AppConfigContext.ts
@@ -0,0 +1,23 @@
+import createContext, { type InferContextApi } from 'react-global-state-hooks/createContext';
+import updateAppConfig from './actions/updateAppConfig';
+import loadAppConfig from './actions/loadAppConfig';
+import initialValue from './helpers/defaultAppConfig';
+
+/**
+ * Creates the AppConfig store
+ * The store includes Context, Provider, and use hook for consuming the context.
+ */
+const appConfig = createContext(initialValue, {
+ actions: {
+ updateAppConfig,
+ loadAppConfig,
+ },
+});
+
+/**
+ * The AppConfig context type.
+ * Represents the shape of the context including state and actions.
+ */
+export type AppConfigApi = InferContextApi;
+
+export default appConfig;
diff --git a/frontend/src/Context/AppConfig/AppConfigContext.types.ts b/frontend/src/Context/AppConfig/AppConfigContext.types.ts
new file mode 100644
index 00000000..1ca9aa5f
--- /dev/null
+++ b/frontend/src/Context/AppConfig/AppConfigContext.types.ts
@@ -0,0 +1,48 @@
+import type { LayoutMode } from '@app-types/session';
+
+export type VideoSettings = {
+ allowBackgroundEffects: boolean;
+ allowCameraControl: boolean;
+ allowVideoOnJoin: boolean;
+ defaultResolution:
+ | '1920x1080'
+ | '1280x960'
+ | '1280x720'
+ | '640x480'
+ | '640x360'
+ | '320x240'
+ | '320x180';
+};
+
+export type AudioSettings = {
+ allowAdvancedNoiseSuppression: boolean;
+ allowAudioOnJoin: boolean;
+ allowMicrophoneControl: boolean;
+};
+
+export type WaitingRoomSettings = {
+ allowDeviceSelection: boolean;
+};
+
+export type MeetingRoomSettings = {
+ allowArchiving: boolean;
+ allowCaptions: boolean;
+ allowChat: boolean;
+ allowDeviceSelection: boolean;
+ allowEmojis: boolean;
+ allowScreenShare: boolean;
+ defaultLayoutMode: LayoutMode;
+ showParticipantList: boolean;
+};
+
+export type AppConfig = {
+ isAppConfigLoaded: boolean;
+
+ videoSettings: VideoSettings;
+
+ audioSettings: AudioSettings;
+
+ waitingRoomSettings: WaitingRoomSettings;
+
+ meetingRoomSettings: MeetingRoomSettings;
+};
diff --git a/frontend/src/Context/AppConfig/actions/loadAppConfig.ts b/frontend/src/Context/AppConfig/actions/loadAppConfig.ts
new file mode 100644
index 00000000..eeaa4b01
--- /dev/null
+++ b/frontend/src/Context/AppConfig/actions/loadAppConfig.ts
@@ -0,0 +1,34 @@
+import type { AppConfig } from '../AppConfigContext.types';
+
+export type AppConfigApi = import('../AppConfigContext').AppConfigApi;
+
+/**
+ * Loads the application configuration from public/config.json file.
+ * If the fetch fails or the content is invalid, it falls back to the default configuration.
+ * Finally, it marks the app config as loaded.
+ * @returns {Function} The thunk action to load the app config.
+ */
+function loadAppConfig(this: AppConfigApi['actions']) {
+ return async (_: AppConfigApi) => {
+ try {
+ const response = await fetch('/config.json', {
+ cache: 'no-cache',
+ });
+
+ const contentType = response.headers.get('content-type');
+ if (!contentType?.includes('application/json')) {
+ throw new Error('No valid JSON found, using default config');
+ }
+
+ const json: Partial = await response.json();
+
+ this.updateAppConfig(json);
+ } finally {
+ this.updateAppConfig({
+ isAppConfigLoaded: true,
+ });
+ }
+ };
+}
+
+export default loadAppConfig;
diff --git a/frontend/src/Context/AppConfig/actions/updateAppConfig.ts b/frontend/src/Context/AppConfig/actions/updateAppConfig.ts
new file mode 100644
index 00000000..70ad3ce8
--- /dev/null
+++ b/frontend/src/Context/AppConfig/actions/updateAppConfig.ts
@@ -0,0 +1,18 @@
+import type { DeepPartial } from '@app-types/index';
+import type { AppConfig } from '../AppConfigContext.types';
+import mergeAppConfigs from '../helpers/mergeAppConfigs';
+
+export type AppConfigApi = import('../AppConfigContext').AppConfigApi;
+
+/**
+ * Partially updates the app config state
+ * @param {DeepPartial} updates - Partial updates to apply to the app config state.
+ * @returns {Function} A function that updates the app config state.
+ */
+function updateAppConfig(this: AppConfigApi['actions'], updates: DeepPartial) {
+ return ({ setState }: AppConfigApi) => {
+ setState((previous) => mergeAppConfigs({ previous, updates }));
+ };
+}
+
+export default updateAppConfig;
diff --git a/frontend/src/Context/AppConfig/helpers/defaultAppConfig.ts b/frontend/src/Context/AppConfig/helpers/defaultAppConfig.ts
new file mode 100644
index 00000000..0f4a6c1b
--- /dev/null
+++ b/frontend/src/Context/AppConfig/helpers/defaultAppConfig.ts
@@ -0,0 +1,35 @@
+import { AppConfig } from '../AppConfigContext.types';
+
+const defaultAppConfig: AppConfig = {
+ isAppConfigLoaded: false,
+
+ videoSettings: {
+ allowBackgroundEffects: true,
+ allowCameraControl: true,
+ allowVideoOnJoin: true,
+ defaultResolution: '1280x720',
+ },
+
+ audioSettings: {
+ allowAdvancedNoiseSuppression: true,
+ allowAudioOnJoin: true,
+ allowMicrophoneControl: true,
+ },
+
+ waitingRoomSettings: {
+ allowDeviceSelection: true,
+ },
+
+ meetingRoomSettings: {
+ allowArchiving: true,
+ allowCaptions: true,
+ allowChat: true,
+ allowDeviceSelection: true,
+ allowEmojis: true,
+ allowScreenShare: true,
+ defaultLayoutMode: 'active-speaker',
+ showParticipantList: true,
+ },
+};
+
+export default defaultAppConfig;
diff --git a/frontend/src/Context/AppConfig/helpers/mergeAppConfigs.ts b/frontend/src/Context/AppConfig/helpers/mergeAppConfigs.ts
new file mode 100644
index 00000000..e59ca1d7
--- /dev/null
+++ b/frontend/src/Context/AppConfig/helpers/mergeAppConfigs.ts
@@ -0,0 +1,55 @@
+import type { DeepPartial } from '@app-types/index';
+import type { AppConfig } from '../AppConfigContext.types';
+import defaultAppConfig from './defaultAppConfig';
+
+/**
+ * Merge the config with the default app config.
+ * @param {DeepPartial} updates - Partial updates to apply to the app config.
+ * @returns {AppConfig} The merged app config.
+ */
+function mergeAppConfigs(updates: DeepPartial): AppConfig;
+
+/**
+ * Merge two app configs.
+ * @param {{ previous: AppConfig; updates: DeepPartial }} args - Object containing the previous app config and partial updates.
+ * @param {AppConfig} args.previous - The previous app config.
+ * @param {DeepPartial} args.updates - Partial updates to apply to the app config.
+ * @returns {AppConfig} The merged app config.
+ */
+function mergeAppConfigs(args: { previous: AppConfig; updates: DeepPartial }): AppConfig;
+
+function mergeAppConfigs(
+ args:
+ | {
+ previous: AppConfig;
+ updates: DeepPartial;
+ }
+ | DeepPartial
+): AppConfig {
+ const shouldUseDefault = !('previous' in args && 'updates' in args);
+ const previous = shouldUseDefault ? defaultAppConfig : args.previous;
+ const updates = shouldUseDefault ? args : args.updates;
+
+ return {
+ ...previous,
+ ...updates,
+ videoSettings: {
+ ...previous.videoSettings,
+ ...updates.videoSettings,
+ },
+ audioSettings: {
+ ...previous.audioSettings,
+ ...updates.audioSettings,
+ },
+ waitingRoomSettings: {
+ ...previous.waitingRoomSettings,
+ ...updates.waitingRoomSettings,
+ },
+ meetingRoomSettings: {
+ ...previous.meetingRoomSettings,
+ ...updates.meetingRoomSettings,
+ },
+ };
+}
+
+export default mergeAppConfigs;
diff --git a/frontend/src/Context/AppConfig/hooks/useAppConfig.ts b/frontend/src/Context/AppConfig/hooks/useAppConfig.ts
new file mode 100644
index 00000000..93c34e9e
--- /dev/null
+++ b/frontend/src/Context/AppConfig/hooks/useAppConfig.ts
@@ -0,0 +1,17 @@
+import appConfig from '../AppConfigContext';
+import { AppConfig } from '../AppConfigContext.types';
+
+/**
+ * Hook to access the AppConfig state with a selector.
+ * @param {Function} selector - Function to select a part of the AppConfig state.
+ * @param {unknown[]} [dependencies] - Optional dependencies array to control re-selection.
+ * @returns {Selection} The selected part of the AppConfig state.
+ */
+function useAppConfig(
+ selector: (state: AppConfig) => Selection,
+ dependencies?: unknown[]
+): Selection {
+ return appConfig.use.select(selector, dependencies);
+}
+
+export default useAppConfig;
diff --git a/frontend/src/Context/AppConfig/hooks/useIsCameraControlAllowed.ts b/frontend/src/Context/AppConfig/hooks/useIsCameraControlAllowed.ts
new file mode 100644
index 00000000..6829dfbf
--- /dev/null
+++ b/frontend/src/Context/AppConfig/hooks/useIsCameraControlAllowed.ts
@@ -0,0 +1,7 @@
+import appConfig from '../AppConfigContext';
+
+const useIsCameraControlAllowed = appConfig.use.createSelectorHook(
+ ({ isAppConfigLoaded, videoSettings }) => isAppConfigLoaded && videoSettings.allowCameraControl
+);
+
+export default useIsCameraControlAllowed;
diff --git a/frontend/src/Context/AppConfig/hooks/useIsMeetingCaptionsAllowed.ts b/frontend/src/Context/AppConfig/hooks/useIsMeetingCaptionsAllowed.ts
new file mode 100644
index 00000000..55291ddb
--- /dev/null
+++ b/frontend/src/Context/AppConfig/hooks/useIsMeetingCaptionsAllowed.ts
@@ -0,0 +1,8 @@
+import appConfig from '../AppConfigContext';
+
+const useIsMeetingCaptionsAllowed = appConfig.use.createSelectorHook(
+ ({ isAppConfigLoaded, meetingRoomSettings }) =>
+ isAppConfigLoaded && meetingRoomSettings.allowCaptions
+);
+
+export default useIsMeetingCaptionsAllowed;
diff --git a/frontend/src/Context/AppConfig/hooks/useIsMeetingChatAllowed.ts b/frontend/src/Context/AppConfig/hooks/useIsMeetingChatAllowed.ts
new file mode 100644
index 00000000..316ac746
--- /dev/null
+++ b/frontend/src/Context/AppConfig/hooks/useIsMeetingChatAllowed.ts
@@ -0,0 +1,7 @@
+import appConfig from '../AppConfigContext';
+
+const useIsMeetingChatAllowed = appConfig.use.createSelectorHook(
+ ({ isAppConfigLoaded, meetingRoomSettings }) => isAppConfigLoaded && meetingRoomSettings.allowChat
+);
+
+export default useIsMeetingChatAllowed;
diff --git a/frontend/src/Context/AppConfig/hooks/useIsMicrophoneControlAllowed.ts b/frontend/src/Context/AppConfig/hooks/useIsMicrophoneControlAllowed.ts
new file mode 100644
index 00000000..f68404ed
--- /dev/null
+++ b/frontend/src/Context/AppConfig/hooks/useIsMicrophoneControlAllowed.ts
@@ -0,0 +1,8 @@
+import appConfig from '../AppConfigContext';
+
+const useIsMicrophoneControlAllowed = appConfig.use.createSelectorHook(
+ ({ isAppConfigLoaded, audioSettings }) =>
+ isAppConfigLoaded && audioSettings.allowMicrophoneControl
+);
+
+export default useIsMicrophoneControlAllowed;
diff --git a/frontend/src/Context/AppConfig/hooks/useShouldShowParticipantList.ts b/frontend/src/Context/AppConfig/hooks/useShouldShowParticipantList.ts
new file mode 100644
index 00000000..b4eed2ca
--- /dev/null
+++ b/frontend/src/Context/AppConfig/hooks/useShouldShowParticipantList.ts
@@ -0,0 +1,8 @@
+import appConfig from '../AppConfigContext';
+
+const useShouldShowParticipantList = appConfig.use.createSelectorHook(
+ ({ isAppConfigLoaded, meetingRoomSettings }) =>
+ isAppConfigLoaded && meetingRoomSettings.showParticipantList
+);
+
+export default useShouldShowParticipantList;
diff --git a/frontend/src/Context/AppConfig/index.tsx b/frontend/src/Context/AppConfig/index.tsx
new file mode 100644
index 00000000..30689857
--- /dev/null
+++ b/frontend/src/Context/AppConfig/index.tsx
@@ -0,0 +1,3 @@
+export type { AppConfig } from './AppConfigContext.types';
+
+export { default, type AppConfigApi } from './AppConfigContext';
diff --git a/frontend/src/Context/BackgroundPublisherProvider/useBackgroundPublisher/useBackgroundPublisher.spec.tsx b/frontend/src/Context/BackgroundPublisherProvider/useBackgroundPublisher/useBackgroundPublisher.spec.tsx
index 4c81490c..94030091 100644
--- a/frontend/src/Context/BackgroundPublisherProvider/useBackgroundPublisher/useBackgroundPublisher.spec.tsx
+++ b/frontend/src/Context/BackgroundPublisherProvider/useBackgroundPublisher/useBackgroundPublisher.spec.tsx
@@ -1,5 +1,5 @@
-import { act, cleanup, renderHook } from '@testing-library/react';
-import { afterAll, afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest';
+import { act, renderHook as renderHookBase } from '@testing-library/react';
+import { afterAll, beforeEach, describe, expect, it, Mock, vi } from 'vitest';
import { hasMediaProcessorSupport, initPublisher, Publisher } from '@vonage/client-sdk-video';
import EventEmitter from 'events';
import useUserContext from '@hooks/useUserContext';
@@ -7,6 +7,7 @@ import usePermissions from '@hooks/usePermissions';
import useDevices from '@hooks/useDevices';
import { allMediaDevices, defaultAudioDevice, defaultVideoDevice } from '@utils/mockData/device';
import { DEVICE_ACCESS_STATUS } from '@utils/constants';
+import appConfig from '@Context/AppConfig';
import { UserContextType } from '../../user';
import useBackgroundPublisher from './useBackgroundPublisher';
import usePublisherOptions from '../../PublisherProvider/usePublisherOptions';
@@ -62,10 +63,6 @@ describe('useBackgroundPublisher', () => {
});
});
- afterEach(() => {
- cleanup();
- });
-
describe('initBackgroundLocalPublisher', () => {
it('should call initBackgroundLocalPublisher', async () => {
mockedInitPublisher.mockReturnValue(mockPublisher);
@@ -250,3 +247,7 @@ describe('useBackgroundPublisher', () => {
});
});
});
+
+function renderHook(render: (initialProps: Props) => Result) {
+ return renderHookBase(render, { wrapper: appConfig.Provider });
+}
diff --git a/frontend/src/Context/ConfigProvider/index.tsx b/frontend/src/Context/ConfigProvider/index.tsx
deleted file mode 100644
index ef121cfb..00000000
--- a/frontend/src/Context/ConfigProvider/index.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-import { createContext, ReactNode, useMemo } from 'react';
-import useConfig, { defaultConfig } from './useConfig';
-
-export type ConfigProviderProps = {
- children: ReactNode;
-};
-
-export type ConfigContextType = ReturnType;
-
-export const ConfigContext = createContext({
- audioSettings: defaultConfig.audioSettings,
- meetingRoomSettings: defaultConfig.meetingRoomSettings,
- waitingRoomSettings: defaultConfig.waitingRoomSettings,
- videoSettings: defaultConfig.videoSettings,
-});
-
-/**
- * ConfigProvider - React Context Provider for ConfigContext
- * ConfigContext contains all application configuration including video settings, audio settings,
- * waiting room settings, and meeting room settings loaded from config.json.
- * We use Context to make the configuration available in many components across the app without
- * prop drilling: https://react.dev/learn/passing-data-deeply-with-context#use-cases-for-context
- * See useConfig.tsx for configuration structure and loading logic
- * @param {ConfigProviderProps} props - The provider properties
- * @property {ReactNode} children - The content to be rendered
- * @returns {ConfigContext} a context provider for application configuration
- */
-export const ConfigProvider = ({ children }: ConfigProviderProps) => {
- const config = useConfig();
- const value = useMemo(() => config, [config]);
-
- return {children};
-};
diff --git a/frontend/src/Context/ConfigProvider/useConfig/index.tsx b/frontend/src/Context/ConfigProvider/useConfig/index.tsx
deleted file mode 100644
index f4720b90..00000000
--- a/frontend/src/Context/ConfigProvider/useConfig/index.tsx
+++ /dev/null
@@ -1,4 +0,0 @@
-import useConfig, { defaultConfig } from './useConfig';
-
-export { defaultConfig };
-export default useConfig;
diff --git a/frontend/src/Context/ConfigProvider/useConfig/useConfig.spec.tsx b/frontend/src/Context/ConfigProvider/useConfig/useConfig.spec.tsx
deleted file mode 100644
index 32057ff5..00000000
--- a/frontend/src/Context/ConfigProvider/useConfig/useConfig.spec.tsx
+++ /dev/null
@@ -1,121 +0,0 @@
-import { describe, expect, it, vi, beforeEach } from 'vitest';
-import { renderHook, waitFor } from '@testing-library/react';
-import useConfig, { AppConfig } from './useConfig';
-
-describe('useConfig', () => {
- const defaultConfig: AppConfig = {
- videoSettings: {
- allowCameraControl: true,
- defaultResolution: '1280x720',
- allowVideoOnJoin: true,
- allowBackgroundEffects: true,
- },
- audioSettings: {
- allowAdvancedNoiseSuppression: true,
- allowAudioOnJoin: true,
- allowMicrophoneControl: true,
- },
- waitingRoomSettings: {
- allowDeviceSelection: true,
- },
- meetingRoomSettings: {
- allowArchiving: true,
- allowCaptions: true,
- allowChat: true,
- allowDeviceSelection: true,
- allowEmojis: true,
- allowScreenShare: true,
- defaultLayoutMode: 'active-speaker',
- showParticipantList: true,
- },
- };
-
- beforeEach(() => {
- vi.spyOn(console, 'log').mockImplementation(vi.fn());
- vi.spyOn(console, 'info').mockImplementation(vi.fn());
- vi.spyOn(console, 'error').mockImplementation(vi.fn());
- });
-
- it('returns the default config when no config.json is loaded', async () => {
- const { result } = renderHook(() => useConfig());
-
- await waitFor(() => {
- expect(result.current).toEqual(defaultConfig);
- });
- });
-
- it('merges config.json values if loaded (mocked fetch)', async () => {
- // All values in this config should override the defaultConfig
- const mockConfig: AppConfig = {
- videoSettings: {
- allowCameraControl: false,
- defaultResolution: '640x480',
- allowVideoOnJoin: false,
- allowBackgroundEffects: false,
- },
- audioSettings: {
- allowAdvancedNoiseSuppression: false,
- allowAudioOnJoin: false,
- allowMicrophoneControl: false,
- },
- waitingRoomSettings: {
- allowDeviceSelection: false,
- },
- meetingRoomSettings: {
- allowArchiving: false,
- allowCaptions: false,
- allowChat: false,
- allowDeviceSelection: false,
- allowEmojis: false,
- allowScreenShare: false,
- defaultLayoutMode: 'grid',
- showParticipantList: false,
- },
- };
-
- vi.spyOn(global, 'fetch').mockResolvedValue({
- json: async () => mockConfig,
- headers: {
- get: () => 'application/json',
- },
- } as unknown as Response);
-
- const { result } = renderHook(() => useConfig());
-
- await waitFor(() => {
- expect(result.current).toMatchObject(mockConfig);
- });
- });
-
- it('falls back to defaultConfig if fetch fails', async () => {
- const mockFetchError = new Error('mocking a failure to fetch');
-
- vi.spyOn(global, 'fetch').mockRejectedValue(mockFetchError as unknown as Response);
-
- const { result } = renderHook(() => useConfig());
-
- await waitFor(() => {
- expect(result.current).toEqual(defaultConfig);
- });
- expect(console.error).toHaveBeenCalledWith('Error loading config:', expect.any(Error));
- });
-
- it('falls back to defaultConfig if no config.json is found', async () => {
- vi.spyOn(global, 'fetch').mockResolvedValue({
- ok: false,
- status: 404,
- statusText: 'Not Found',
- headers: {
- get: () => 'text/html',
- },
- } as unknown as Response);
-
- const { result } = renderHook(() => useConfig());
-
- await waitFor(() => {
- expect(result.current).toEqual(defaultConfig);
- });
- expect(console.info).toHaveBeenCalledWith('No valid JSON found, using default config');
- expect(console.error).not.toHaveBeenCalled();
- });
-});
diff --git a/frontend/src/Context/ConfigProvider/useConfig/useConfig.tsx b/frontend/src/Context/ConfigProvider/useConfig/useConfig.tsx
deleted file mode 100644
index 41e42108..00000000
--- a/frontend/src/Context/ConfigProvider/useConfig/useConfig.tsx
+++ /dev/null
@@ -1,134 +0,0 @@
-import { useMemo, useEffect, useState } from 'react';
-import { LayoutMode } from '../../../types/session';
-
-export type VideoSettings = {
- allowBackgroundEffects: boolean;
- allowCameraControl: boolean;
- allowVideoOnJoin: boolean;
- defaultResolution:
- | '1920x1080'
- | '1280x960'
- | '1280x720'
- | '640x480'
- | '640x360'
- | '320x240'
- | '320x180';
-};
-
-export type AudioSettings = {
- allowAdvancedNoiseSuppression: boolean;
- allowAudioOnJoin: boolean;
- allowMicrophoneControl: boolean;
-};
-
-export type WaitingRoomSettings = {
- allowDeviceSelection: boolean;
-};
-
-export type MeetingRoomSettings = {
- allowArchiving: boolean;
- allowCaptions: boolean;
- allowChat: boolean;
- allowDeviceSelection: boolean;
- allowEmojis: boolean;
- allowScreenShare: boolean;
- defaultLayoutMode: LayoutMode;
- showParticipantList: boolean;
-};
-
-export type AppConfig = {
- videoSettings: VideoSettings;
- audioSettings: AudioSettings;
- waitingRoomSettings: WaitingRoomSettings;
- meetingRoomSettings: MeetingRoomSettings;
-};
-
-export const defaultConfig: AppConfig = {
- videoSettings: {
- allowBackgroundEffects: true,
- allowCameraControl: true,
- allowVideoOnJoin: true,
- defaultResolution: '1280x720',
- },
- audioSettings: {
- allowAdvancedNoiseSuppression: true,
- allowAudioOnJoin: true,
- allowMicrophoneControl: true,
- },
- waitingRoomSettings: {
- allowDeviceSelection: true,
- },
- meetingRoomSettings: {
- allowArchiving: true,
- allowCaptions: true,
- allowChat: true,
- allowDeviceSelection: true,
- allowEmojis: true,
- allowScreenShare: true,
- defaultLayoutMode: 'active-speaker',
- showParticipantList: true,
- },
-};
-
-/**
- * Hook wrapper for application configuration. Provides comprehensive application configuration
- * including video settings (background effects, camera control, resolution), audio settings
- * (noise suppression, microphone control), waiting room settings (device selection), and
- * meeting room settings (layout mode, UI button visibility). To configure settings, edit the
- * `vonage-video-react-app/public/config.json` file.
- * @returns {AppConfig} The application configuration with video, audio, waiting room, and meeting room settings
- */
-const useConfig = (): AppConfig => {
- const [config, setConfig] = useState(defaultConfig);
-
- useEffect(() => {
- // Try to load config from JSON file located at frontend/public/config.json
- const loadConfig = async () => {
- try {
- const response = await fetch('/config.json');
-
- const contentType = response.headers.get('content-type');
- if (!contentType || !contentType.includes('application/json')) {
- console.info('No valid JSON found, using default config');
- return;
- }
-
- const json = await response.json();
- setConfig(json);
- } catch (error) {
- console.error('Error loading config:', error);
- }
- };
-
- loadConfig();
- }, []);
-
- const mergedConfig: AppConfig = useMemo(() => {
- const typedConfigFile = config as Partial;
-
- return {
- ...defaultConfig,
- ...typedConfigFile,
- videoSettings: {
- ...defaultConfig.videoSettings,
- ...(typedConfigFile.videoSettings || {}),
- },
- audioSettings: {
- ...defaultConfig.audioSettings,
- ...(typedConfigFile.audioSettings || {}),
- },
- waitingRoomSettings: {
- ...defaultConfig.waitingRoomSettings,
- ...(typedConfigFile.waitingRoomSettings || {}),
- },
- meetingRoomSettings: {
- ...defaultConfig.meetingRoomSettings,
- ...(typedConfigFile.meetingRoomSettings || {}),
- },
- };
- }, [config]);
-
- return mergedConfig;
-};
-
-export default useConfig;
diff --git a/frontend/src/Context/PreviewPublisherProvider/usePreviewPublisher/usePreviewPublisher.spec.tsx b/frontend/src/Context/PreviewPublisherProvider/usePreviewPublisher/usePreviewPublisher.spec.tsx
index b6f0e8cd..717c625b 100644
--- a/frontend/src/Context/PreviewPublisherProvider/usePreviewPublisher/usePreviewPublisher.spec.tsx
+++ b/frontend/src/Context/PreviewPublisherProvider/usePreviewPublisher/usePreviewPublisher.spec.tsx
@@ -1,4 +1,4 @@
-import { act, renderHook } from '@testing-library/react';
+import { act, renderHook as renderHookBase } from '@testing-library/react';
import { afterAll, beforeEach, describe, expect, it, Mock, vi } from 'vitest';
import { hasMediaProcessorSupport, initPublisher, Publisher } from '@vonage/client-sdk-video';
import EventEmitter from 'events';
@@ -7,6 +7,7 @@ import usePermissions from '@hooks/usePermissions';
import useDevices from '@hooks/useDevices';
import { allMediaDevices, defaultAudioDevice, defaultVideoDevice } from '@utils/mockData/device';
import { DEVICE_ACCESS_STATUS } from '@utils/constants';
+import appConfig from '@Context/AppConfig';
import { UserContextType } from '../../user';
import usePreviewPublisher from './usePreviewPublisher';
@@ -237,3 +238,7 @@ describe('usePreviewPublisher', () => {
});
});
});
+
+function renderHook(render: (initialProps: Props) => Result) {
+ return renderHookBase(render, { wrapper: appConfig.Provider });
+}
diff --git a/frontend/src/Context/PreviewPublisherProvider/usePreviewPublisher/usePreviewPublisher.tsx b/frontend/src/Context/PreviewPublisherProvider/usePreviewPublisher/usePreviewPublisher.tsx
index 75d90ae8..a499771b 100644
--- a/frontend/src/Context/PreviewPublisherProvider/usePreviewPublisher/usePreviewPublisher.tsx
+++ b/frontend/src/Context/PreviewPublisherProvider/usePreviewPublisher/usePreviewPublisher.tsx
@@ -7,6 +7,7 @@ import {
hasMediaProcessorSupport,
PublisherProperties,
} from '@vonage/client-sdk-video';
+import useAppConfig from '@Context/AppConfig/hooks/useAppConfig';
import setMediaDevices from '../../../utils/mediaDeviceUtils';
import useDevices from '../../../hooks/useDevices';
import usePermissions from '../../../hooks/usePermissions';
@@ -17,7 +18,6 @@ import { AccessDeniedEvent } from '../../PublisherProvider/usePublisher/usePubli
import DeviceStore from '../../../utils/DeviceStore';
import { setStorageItem, STORAGE_KEYS } from '../../../utils/storage';
import applyBackgroundFilter from '../../../utils/backgroundFilter/applyBackgroundFilter/applyBackgroundFilter';
-import useConfigContext from '../../../hooks/useConfigContext';
type PublisherVideoElementCreatedEvent = Event<'videoElementCreated', Publisher> & {
element: HTMLVideoElement | HTMLObjectElement;
@@ -67,7 +67,7 @@ export type PreviewPublisherContextType = {
*/
const usePreviewPublisher = (): PreviewPublisherContextType => {
const { setUser, user } = useUserContext();
- const config = useConfigContext();
+ const defaultResolution = useAppConfig(({ videoSettings }) => videoSettings.defaultResolution);
const { allMediaDevices, getAllMediaDevices } = useDevices();
const [publisherVideoElement, setPublisherVideoElement] = useState<
HTMLVideoElement | HTMLObjectElement
@@ -87,7 +87,6 @@ const usePreviewPublisher = (): PreviewPublisherContextType => {
const [localVideoSource, setLocalVideoSource] = useState(undefined);
const [localAudioSource, setLocalAudioSource] = useState(undefined);
const deviceStoreRef = useRef(new DeviceStore());
- const { defaultResolution } = config.videoSettings;
/* This sets the default devices in use so that the user knows what devices they are using */
useEffect(() => {
diff --git a/frontend/src/Context/PublisherProvider/usePublisher/usePublisher.spec.tsx b/frontend/src/Context/PublisherProvider/usePublisher/usePublisher.spec.tsx
index 0603e83e..7e92f746 100644
--- a/frontend/src/Context/PublisherProvider/usePublisher/usePublisher.spec.tsx
+++ b/frontend/src/Context/PublisherProvider/usePublisher/usePublisher.spec.tsx
@@ -1,5 +1,5 @@
import { beforeEach, describe, it, expect, vi } from 'vitest';
-import { act, renderHook, waitFor } from '@testing-library/react';
+import { act, renderHook as renderHookBase, waitFor } from '@testing-library/react';
import {
initPublisher,
Publisher,
@@ -9,6 +9,7 @@ import {
import EventEmitter from 'events';
import useUserContext from '@hooks/useUserContext';
import useSessionContext from '@hooks/useSessionContext';
+import appConfig from '@Context/AppConfig';
import usePublisher from './usePublisher';
import { UserContextType } from '../../user';
import { SessionContextType } from '../../SessionProvider/session';
@@ -284,3 +285,7 @@ describe('usePublisher', () => {
});
});
});
+
+function renderHook(render: (initialProps: Props) => Result) {
+ return renderHookBase(render, { wrapper: appConfig.Provider });
+}
diff --git a/frontend/src/Context/PublisherProvider/usePublisherOptions/usePublisherOptions.spec.tsx b/frontend/src/Context/PublisherProvider/usePublisherOptions/usePublisherOptions.spec.tsx
index 0f081a66..5e5c2596 100644
--- a/frontend/src/Context/PublisherProvider/usePublisherOptions/usePublisherOptions.spec.tsx
+++ b/frontend/src/Context/PublisherProvider/usePublisherOptions/usePublisherOptions.spec.tsx
@@ -1,17 +1,15 @@
import { describe, expect, it, vi, beforeEach, afterAll } from 'vitest';
-import { renderHook, waitFor } from '@testing-library/react';
+import { renderHook as renderHookBase, waitFor } from '@testing-library/react';
import OT from '@vonage/client-sdk-video';
import useUserContext from '@hooks/useUserContext';
import localStorageMock from '@utils/mockData/localStorageMock';
import DeviceStore from '@utils/DeviceStore';
import { setStorageItem, STORAGE_KEYS } from '@utils/storage';
-import useConfigContext from '@hooks/useConfigContext';
+import { AppConfigProviderWrapperOptions, makeAppConfigProviderWrapper } from '@test/providers';
import usePublisherOptions from './usePublisherOptions';
import { UserContextType } from '../../user';
-import { ConfigContextType } from '../../ConfigProvider';
vi.mock('@hooks/useUserContext.tsx');
-vi.mock('@hooks/useConfigContext');
const defaultSettings = {
publishAudio: false,
@@ -52,7 +50,6 @@ const mockUserContextWithCustomSettings = {
describe('usePublisherOptions', () => {
let enumerateDevicesMock: ReturnType;
let deviceStore: DeviceStore;
- let configContext: ConfigContextType;
beforeEach(async () => {
enumerateDevicesMock = vi.fn();
@@ -68,17 +65,6 @@ describe('usePublisherOptions', () => {
deviceStore = new DeviceStore();
enumerateDevicesMock.mockResolvedValue([]);
await deviceStore.init();
- configContext = {
- audioSettings: {
- allowAudioOnJoin: true,
- },
- videoSettings: {
- defaultResolution: '1280x720',
- allowVideoOnJoin: true,
- },
- } as Partial as ConfigContextType;
-
- vi.mocked(useConfigContext).mockReturnValue(configContext);
vi.mocked(useUserContext).mockImplementation(() => mockUserContextWithDefaultSettings);
});
@@ -158,27 +144,62 @@ describe('usePublisherOptions', () => {
describe('configurable features', () => {
it('should disable audio publishing when allowAudioOnJoin is false', async () => {
- configContext.audioSettings.allowAudioOnJoin = false;
- const { result } = renderHook(() => usePublisherOptions());
+ const { result } = renderHook(() => usePublisherOptions(), {
+ appConfigOptions: {
+ value: {
+ audioSettings: {
+ allowAudioOnJoin: false,
+ },
+ },
+ },
+ });
+
await waitFor(() => {
expect(result.current?.publishAudio).toBe(false);
});
});
it('should disable video publishing when allowVideoOnJoin is false', async () => {
- configContext.videoSettings.allowVideoOnJoin = false;
- const { result } = renderHook(() => usePublisherOptions());
+ const { result } = renderHook(() => usePublisherOptions(), {
+ appConfigOptions: {
+ value: {
+ audioSettings: {
+ allowAudioOnJoin: false,
+ },
+ },
+ },
+ });
+
await waitFor(() => {
expect(result.current?.publishVideo).toBe(false);
});
});
it('should configure resolution from config', async () => {
- configContext.videoSettings.defaultResolution = '640x480';
- const { result } = renderHook(() => usePublisherOptions());
+ const { result } = renderHook(() => usePublisherOptions(), {
+ appConfigOptions: {
+ value: {
+ videoSettings: {
+ defaultResolution: '640x480',
+ },
+ },
+ },
+ });
+
await waitFor(() => {
expect(result.current?.resolution).toBe('640x480');
});
});
});
});
+
+function renderHook(
+ render: (initialProps: Props) => Result,
+ options?: {
+ appConfigOptions?: AppConfigProviderWrapperOptions;
+ }
+) {
+ const { AppConfigWrapper } = makeAppConfigProviderWrapper(options?.appConfigOptions);
+
+ return renderHookBase(render, { ...options, wrapper: AppConfigWrapper });
+}
diff --git a/frontend/src/Context/PublisherProvider/usePublisherOptions/usePublisherOptions.tsx b/frontend/src/Context/PublisherProvider/usePublisherOptions/usePublisherOptions.tsx
index 2ab22c74..5e6fb964 100644
--- a/frontend/src/Context/PublisherProvider/usePublisherOptions/usePublisherOptions.tsx
+++ b/frontend/src/Context/PublisherProvider/usePublisherOptions/usePublisherOptions.tsx
@@ -5,10 +5,10 @@ import {
AudioFilter,
hasMediaProcessorSupport,
} from '@vonage/client-sdk-video';
-import useUserContext from '../../../hooks/useUserContext';
-import getInitials from '../../../utils/getInitials';
-import DeviceStore from '../../../utils/DeviceStore';
-import useConfigContext from '../../../hooks/useConfigContext';
+import useAppConfig from '@Context/AppConfig/hooks/useAppConfig';
+import useUserContext from '@hooks/useUserContext';
+import getInitials from '@utils/getInitials';
+import DeviceStore from '@utils/DeviceStore';
/**
* React hook to get PublisherProperties combining default options and options set in UserContext
@@ -17,11 +17,13 @@ import useConfigContext from '../../../hooks/useConfigContext';
const usePublisherOptions = (): PublisherProperties | null => {
const { user } = useUserContext();
- const config = useConfigContext();
+
+ const defaultResolution = useAppConfig(({ videoSettings }) => videoSettings.defaultResolution);
+ const allowVideoOnJoin = useAppConfig(({ videoSettings }) => videoSettings.allowVideoOnJoin);
+ const allowAudioOnJoin = useAppConfig(({ audioSettings }) => audioSettings.allowAudioOnJoin);
+
const [publisherOptions, setPublisherOptions] = useState(null);
const deviceStoreRef = useRef(null);
- const { defaultResolution, allowVideoOnJoin } = config.videoSettings;
- const { allowAudioOnJoin } = config.audioSettings;
useEffect(() => {
const setOptions = async () => {
diff --git a/frontend/src/Context/RoomContext.tsx b/frontend/src/Context/RoomContext.tsx
index 31cce11c..a7850863 100644
--- a/frontend/src/Context/RoomContext.tsx
+++ b/frontend/src/Context/RoomContext.tsx
@@ -3,15 +3,18 @@ import { ReactElement } from 'react';
import RedirectToUnsupportedBrowserPage from '../components/RedirectToUnsupportedBrowserPage';
import { AudioOutputProvider } from './AudioOutputProvider';
import UserProvider from './user';
-import { ConfigProvider } from './ConfigProvider';
+import appConfig from './AppConfig';
import { BackgroundPublisherProvider } from './BackgroundPublisherProvider';
+import type { AppConfig, AppConfigApi } from './AppConfig';
/**
* Wrapper for all of the contexts used by the waiting room and the meeting room.
+ * @param {object} props - The component props.
+ * @param {AppConfig} [props.appConfigValue] - Optional AppConfig value to initialize the context with... For testing purposes.
* @returns {ReactElement} The context.
*/
-const RoomContext = (): ReactElement => (
-
+const RoomContext = ({ appConfigValue }: { appConfigValue?: AppConfig }): ReactElement => (
+
@@ -21,7 +24,21 @@ const RoomContext = (): ReactElement => (
-
+
);
+/**
+ * Fetches the app static configuration if it has not been loaded yet.
+ * @param {AppConfigApi} context - The AppConfig context.
+ */
+function fetchAppConfiguration(context: AppConfigApi): void {
+ const { isAppConfigLoaded } = context.getState();
+
+ if (isAppConfigLoaded) {
+ return;
+ }
+
+ context.actions.loadAppConfig();
+}
+
export default RoomContext;
diff --git a/frontend/src/Context/SessionProvider/session.spec.tsx b/frontend/src/Context/SessionProvider/session.spec.tsx
index c0754cf3..3dad382e 100644
--- a/frontend/src/Context/SessionProvider/session.spec.tsx
+++ b/frontend/src/Context/SessionProvider/session.spec.tsx
@@ -1,47 +1,32 @@
-import { useEffect } from 'react';
-import { describe, expect, it, vi, beforeEach, Mock, afterAll, afterEach } from 'vitest';
-import { act, render, waitFor } from '@testing-library/react';
+import { ReactElement, useEffect } from 'react';
+import { describe, expect, it, vi, beforeEach, Mock } from 'vitest';
+import { act, render as renderBase, waitFor } from '@testing-library/react';
import EventEmitter from 'events';
import { Publisher, Stream } from '@vonage/client-sdk-video';
-import useSessionContext from '../../hooks/useSessionContext';
-import SessionProvider from './session';
-import ActiveSpeakerTracker from '../../utils/ActiveSpeakerTracker';
-import useUserContext from '../../hooks/useUserContext';
-import VonageVideoClient from '../../utils/VonageVideoClient';
-import { Credential, StreamPropertyChangedEvent, SubscriberWrapper } from '../../types/session';
-import fetchCredentials from '../../api/fetchCredentials';
-
-vi.mock('../../utils/ActiveSpeakerTracker');
-vi.mock('../../hooks/useUserContext');
-vi.mock('../../utils/VonageVideoClient');
+import { makeSessionProviderWrapper } from '@test/providers';
+import useSessionContext from '@hooks/useSessionContext';
+import ActiveSpeakerTracker from '@utils/ActiveSpeakerTracker';
+import VonageVideoClient from '@utils/VonageVideoClient';
+import { Credential, StreamPropertyChangedEvent, SubscriberWrapper } from '@app-types/session';
+import fetchCredentials from '@api/fetchCredentials';
+import { UserType } from '@Context/user';
+
+vi.mock('@utils/ActiveSpeakerTracker');
+vi.mock('@utils/VonageVideoClient');
+
// Override the constants for max pinning test
-vi.mock('../../utils/constants', () => ({
+vi.mock('@utils/constants', () => ({
MAX_PIN_COUNT_MOBILE: 1,
MAX_PIN_COUNT_DESKTOP: 1,
}));
-vi.mock('../../api/fetchCredentials');
-vi.mock('../../hooks/useConfigContext', () => {
- return {
- default: () => ({
- meetingRoomSettings: {
- defaultLayoutMode: 'active-speaker',
- },
- }),
- };
-});
+
+vi.mock('@api/fetchCredentials');
const mockFetchCredentials = fetchCredentials as Mock;
describe('SessionProvider', () => {
let activeSpeakerTracker: ActiveSpeakerTracker;
- let mockUserContext: {
- user: {
- defaultSettings: { name: string };
- issues: {
- reconnections: number;
- };
- };
- };
+
let vonageVideoClient: VonageVideoClient;
let getByTestId: (id: string) => HTMLElement;
@@ -145,14 +130,7 @@ describe('SessionProvider', () => {
onSubscriberDestroyed: vi.fn(),
onSubscriberAudioLevelUpdated: vi.fn(),
}) as unknown as ActiveSpeakerTracker;
- mockUserContext = {
- user: {
- defaultSettings: { name: 'TestUser' },
- issues: {
- reconnections: 0,
- },
- },
- };
+
vonageVideoClient = Object.assign(new EventEmitter(), {
unpublish: vi.fn(),
publish: vi.fn().mockResolvedValue(undefined),
@@ -160,7 +138,7 @@ describe('SessionProvider', () => {
disconnect: vi.fn(),
forceMuteStream: vi.fn(),
}) as unknown as VonageVideoClient;
- (useUserContext as Mock).mockReturnValue(mockUserContext);
+
const mockedActiveSpeakerTracker = vi.mocked(ActiveSpeakerTracker);
mockedActiveSpeakerTracker.mockImplementation(() => {
return activeSpeakerTracker;
@@ -176,23 +154,11 @@ describe('SessionProvider', () => {
} as Credential);
await act(async () => {
- const result = render(
-
-
-
- );
+ const result = render();
getByTestId = result.getByTestId;
});
});
- afterEach(() => {
- vi.resetAllMocks();
- });
-
- afterAll(() => {
- vi.restoreAllMocks();
- });
-
it('should update activeSpeaker state when activeSpeakerTracker emits event', async () => {
act(() =>
activeSpeakerTracker.emit('activeSpeakerChanged', {
@@ -438,3 +404,18 @@ describe('SessionProvider', () => {
expect(vonageVideoClient.connect).toHaveBeenCalledTimes(1);
});
});
+
+function render(ui: ReactElement) {
+ const { SessionProviderWrapper } = makeSessionProviderWrapper({
+ userOptions: {
+ value: {
+ defaultSettings: { name: 'TestUser' },
+ issues: {
+ reconnections: 0,
+ },
+ } as UserType,
+ },
+ });
+
+ return renderBase(ui, { wrapper: SessionProviderWrapper });
+}
diff --git a/frontend/src/Context/SessionProvider/session.tsx b/frontend/src/Context/SessionProvider/session.tsx
index 5a835013..dc0d094d 100644
--- a/frontend/src/Context/SessionProvider/session.tsx
+++ b/frontend/src/Context/SessionProvider/session.tsx
@@ -11,11 +11,13 @@ import {
ReactElement,
} from 'react';
import { Connection, Publisher, Stream } from '@vonage/client-sdk-video';
-import fetchCredentials from '../../api/fetchCredentials';
-import useUserContext from '../../hooks/useUserContext';
-import useConfigContext from '../../hooks/useConfigContext';
-import ActiveSpeakerTracker from '../../utils/ActiveSpeakerTracker';
-import useRightPanel, { RightPanelActiveTab } from '../../hooks/useRightPanel';
+import useRightPanel, { RightPanelActiveTab } from '@hooks/useRightPanel';
+import useUserContext from '@hooks/useUserContext';
+import useChat from '@hooks/useChat';
+import useEmoji, { EmojiWrapper } from '@hooks/useEmoji';
+import appConfigContext from '@Context/AppConfig';
+import fetchCredentials from '@api/fetchCredentials';
+import ActiveSpeakerTracker from '@utils/ActiveSpeakerTracker';
import {
Credential,
LocalCaptionReceived,
@@ -24,19 +26,17 @@ import {
SubscriberAudioLevelUpdatedEvent,
SubscriberWrapper,
LayoutMode,
-} from '../../types/session';
-import useChat from '../../hooks/useChat';
-import { ChatMessageType } from '../../types/chat';
-import { isMobile } from '../../utils/util';
+} from '@app-types/session';
+import { ChatMessageType } from '@app-types/chat';
+import { isMobile } from '@utils/util';
import {
sortByDisplayPriority,
togglePinAndSortByDisplayOrder,
-} from '../../utils/sessionStateOperations';
-import { MAX_PIN_COUNT_DESKTOP, MAX_PIN_COUNT_MOBILE } from '../../utils/constants';
-import VonageVideoClient from '../../utils/VonageVideoClient';
-import useEmoji, { EmojiWrapper } from '../../hooks/useEmoji';
+} from '@utils/sessionStateOperations';
+import { MAX_PIN_COUNT_DESKTOP, MAX_PIN_COUNT_MOBILE } from '@utils/constants';
+import VonageVideoClient from '@utils/VonageVideoClient';
-export type { ChatMessageType } from '../../types/chat';
+export type { ChatMessageType } from '@app-types/chat';
export type SessionContextType = {
vonageVideoClient: null | VonageVideoClient;
@@ -130,16 +130,19 @@ const MAX_PIN_COUNT = isMobile() ? MAX_PIN_COUNT_MOBILE : MAX_PIN_COUNT_DESKTOP;
* @returns {SessionContextType} a context provider for a publisher preview
*/
const SessionProvider = ({ children }: SessionProviderProps): ReactElement => {
- const config = useConfigContext();
+ const appConfig = appConfigContext.use.api();
+
const [lastStreamUpdate, setLastStreamUpdate] = useState(null);
const vonageVideoClient = useRef(null);
const [reconnecting, setReconnecting] = useState(false);
const [subscriberWrappers, setSubscriberWrappers] = useState([]);
const [subscriptionError, setSubscriptionError] = useState(null);
const [ownCaptions, setOwnCaptions] = useState(null);
- const [layoutMode, setLayoutMode] = useState(
- config.meetingRoomSettings.defaultLayoutMode
- );
+
+ const [layoutMode, setLayoutMode] = useState(() => {
+ return appConfig.getState().meetingRoomSettings.defaultLayoutMode;
+ });
+
const [archiveId, setArchiveId] = useState(null);
const activeSpeakerTracker = useRef(new ActiveSpeakerTracker());
const [activeSpeakerId, setActiveSpeakerId] = useState();
diff --git a/frontend/src/Context/tests/RoomContext.spec.tsx b/frontend/src/Context/tests/RoomContext.spec.tsx
index 361cb9a8..a11c01c4 100644
--- a/frontend/src/Context/tests/RoomContext.spec.tsx
+++ b/frontend/src/Context/tests/RoomContext.spec.tsx
@@ -5,17 +5,13 @@ import { PropsWithChildren } from 'react';
import useUserContext from '@hooks/useUserContext';
import useAudioOutputContext from '@hooks/useAudioOutputContext';
import { nativeDevices } from '@utils/mockData/device';
+import mergeAppConfigs from '@Context/AppConfig/helpers/mergeAppConfigs';
import RoomContext from '../RoomContext';
import { UserContextType } from '../user';
import { AudioOutputContextType } from '../AudioOutputProvider';
vi.mock('@hooks/useUserContext');
vi.mock('@hooks/useAudioOutputContext');
-vi.mock('../ConfigProvider', () => ({
- __esModule: true,
- ConfigProvider: ({ children }: PropsWithChildren) => children,
- default: ({ children }: PropsWithChildren) => children,
-}));
vi.mock('../BackgroundPublisherProvider', () => ({
__esModule: true,
BackgroundPublisherProvider: ({ children }: PropsWithChildren) => children,
@@ -35,6 +31,13 @@ const mockUseAudioOutputContextValues = {
currentAudioOutputDevice: fakeAudioOutput,
} as AudioOutputContextType;
+const defaultAppConfigValue = mergeAppConfigs({
+ /**
+ * This flag prevents the provider from attempting to load the config.json file
+ */
+ isAppConfigLoaded: true,
+});
+
describe('RoomContext', () => {
const nativeMediaDevices = global.navigator.mediaDevices;
beforeEach(() => {
@@ -69,7 +72,7 @@ describe('RoomContext', () => {
render(
- }>
+ }>
} />
@@ -95,7 +98,7 @@ describe('RoomContext', () => {
render(
- }>
+ }>
} />
diff --git a/frontend/src/Context/user.tsx b/frontend/src/Context/user.tsx
index 4a8876fd..d48ec840 100644
--- a/frontend/src/Context/user.tsx
+++ b/frontend/src/Context/user.tsx
@@ -41,6 +41,7 @@ export const UserContext = createContext(null);
export type UserProviderProps = {
children: ReactNode;
+ value?: UserType;
};
/**
@@ -48,28 +49,30 @@ export type UserProviderProps = {
* @param {UserProviderProps} props - The props for the context.
* @returns {ReactElement} a context provider for the User.
*/
-const UserProvider = ({ children }: UserProviderProps): ReactElement => {
+const UserProvider = ({ children, value: initialUserState }: UserProviderProps): ReactElement => {
// Load initial settings from local storage
const noiseSuppression = getStorageItem(STORAGE_KEYS.NOISE_SUPPRESSION) === 'true';
const backgroundFilter = parseVideoFilter(getStorageItem(STORAGE_KEYS.BACKGROUND_REPLACEMENT));
const name = getStorageItem(STORAGE_KEYS.USERNAME) ?? '';
- const [user, setUser] = useState({
- defaultSettings: {
- publishAudio: true,
- publishVideo: true,
- name,
- backgroundFilter,
- noiseSuppression,
- audioSource: undefined,
- videoSource: undefined,
- publishCaptions: true,
- },
- issues: {
- reconnections: 0, // Start with zero reconnections
- audioFallbacks: 0, // Start with zero audio fallbacks
- },
- });
+ const [user, setUser] = useState(
+ initialUserState ?? {
+ defaultSettings: {
+ publishAudio: true,
+ publishVideo: true,
+ name,
+ backgroundFilter,
+ noiseSuppression,
+ audioSource: undefined,
+ videoSource: undefined,
+ publishCaptions: true,
+ },
+ issues: {
+ reconnections: 0, // Start with zero reconnections
+ audioFallbacks: 0, // Start with zero audio fallbacks
+ },
+ }
+ );
const value = useMemo(
() => ({
diff --git a/frontend/src/components/HiddenParticipantsTile/HiddenParticipantsTile.spec.tsx b/frontend/src/components/HiddenParticipantsTile/HiddenParticipantsTile.spec.tsx
index ac21a4c1..501f566d 100644
--- a/frontend/src/components/HiddenParticipantsTile/HiddenParticipantsTile.spec.tsx
+++ b/frontend/src/components/HiddenParticipantsTile/HiddenParticipantsTile.spec.tsx
@@ -1,24 +1,13 @@
-import { fireEvent, render, screen } from '@testing-library/react';
-import { describe, it, expect, vi, beforeEach, Mock, afterEach } from 'vitest';
+import { ReactElement } from 'react';
+import { fireEvent, render as renderBase } from '@testing-library/react';
+import { describe, it, expect, vi } from 'vitest';
import userEvent from '@testing-library/user-event';
import { Subscriber } from '@vonage/client-sdk-video';
-import { SubscriberWrapper } from '../../types/session';
+import { makeSessionProviderWrapper, type SessionProviderWrapperOptions } from '@test/providers';
+import { SubscriberWrapper } from '@app-types/session';
import HiddenParticipantsTile from './index';
-import useConfigContext from '../../hooks/useConfigContext';
-import { ConfigContextType } from '../../Context/ConfigProvider';
-
-const mockToggleParticipantList = vi.fn();
-vi.mock('../../hooks/useSessionContext', () => ({
- __esModule: true,
- default: () => ({
- toggleParticipantList: mockToggleParticipantList,
- }),
-}));
-const mockUseConfigContext = useConfigContext as Mock<[], ConfigContextType>;
-vi.mock('../../hooks/useConfigContext');
describe('HiddenParticipantsTile', () => {
- let configContext: ConfigContextType;
const box = { height: 100, width: 100, top: 0, left: 0 };
const hiddenSubscribers = [
{
@@ -41,19 +30,6 @@ describe('HiddenParticipantsTile', () => {
},
];
- beforeEach(() => {
- configContext = {
- meetingRoomSettings: {
- showParticipantList: true,
- },
- } as Partial as ConfigContextType;
- mockUseConfigContext.mockReturnValue(configContext);
- });
-
- afterEach(() => {
- vi.resetAllMocks();
- });
-
it('should display two hidden participants', async () => {
const currentHiddenSubscribers = [
...hiddenSubscribers,
@@ -77,50 +53,85 @@ describe('HiddenParticipantsTile', () => {
},
] as SubscriberWrapper[];
- render();
+ const { sessionContext, getByTestId, getByText } = render(
+
+ );
+
+ // we need to capture it before re-render cause the function doesn't persist across renders
+ const toggleParticipantListSpy = vi.mocked(sessionContext.current.toggleParticipantList);
- const button = screen.getByTestId('hidden-participants');
+ const button = getByTestId('hidden-participants');
expect(button).toBeInTheDocument();
await userEvent.click(button);
fireEvent.mouseEnter(button);
fireEvent.mouseLeave(button);
- expect(screen.getByText('JD')).toBeInTheDocument();
- expect(screen.getByText('JS')).toBeInTheDocument();
+ expect(getByText('JD')).toBeInTheDocument();
+ expect(getByText('JS')).toBeInTheDocument();
- expect(mockToggleParticipantList).toHaveBeenCalled();
+ expect(toggleParticipantListSpy).toHaveBeenCalled();
});
it('should display one hidden participant because the other one is empty', async () => {
const currentHiddenSubscribers = [...hiddenSubscribers, {}] as SubscriberWrapper[];
- render();
+ const { sessionContext, getByText, getByTestId, queryByText } = render(
+
+ );
+
+ // we need to capture it before re-render cause the function doesn't persist across renders
+ const toggleParticipantListSpy = vi.mocked(sessionContext.current.toggleParticipantList);
- const button = screen.getByTestId('hidden-participants');
+ const button = getByTestId('hidden-participants');
expect(button).toBeInTheDocument();
await userEvent.click(button);
- expect(screen.getByText('JD')).toBeInTheDocument();
- expect(screen.queryByText('JS')).not.toBeInTheDocument();
+ expect(getByText('JD')).toBeInTheDocument();
+ expect(queryByText('JS')).not.toBeInTheDocument();
- expect(mockToggleParticipantList).toHaveBeenCalled();
+ expect(toggleParticipantListSpy).toHaveBeenCalled();
});
it('does not toggle participant list when showParticipantList is disabled', async () => {
const currentHiddenSubscribers = [...hiddenSubscribers, {}] as SubscriberWrapper[];
- mockUseConfigContext.mockReturnValue({
- meetingRoomSettings: {
- showParticipantList: false,
- },
- } as Partial as ConfigContextType);
-
- render();
- const button = screen.getByTestId('hidden-participants');
+ const { sessionContext, getByTestId } = render(
+ ,
+ {
+ appConfigOptions: {
+ value: {
+ isAppConfigLoaded: false,
+ meetingRoomSettings: {
+ showParticipantList: false,
+ },
+ },
+ },
+ }
+ );
+
+ // we need to capture it before re-render cause the function doesn't persist across renders
+ const toggleParticipantListSpy = vi.mocked(sessionContext.current.toggleParticipantList);
+
+ const button = getByTestId('hidden-participants');
expect(button).toBeInTheDocument();
await userEvent.click(button);
- expect(mockToggleParticipantList).not.toHaveBeenCalled();
+ expect(toggleParticipantListSpy).not.toHaveBeenCalled();
});
});
+
+function render(ui: ReactElement, options?: SessionProviderWrapperOptions) {
+ const { SessionProviderWrapper, sessionContext, appConfigContext } = makeSessionProviderWrapper({
+ __onCreated: (sessionContext) => {
+ vi.spyOn(sessionContext, 'toggleParticipantList');
+ },
+ ...options,
+ });
+
+ return {
+ ...renderBase(ui, { ...options, wrapper: SessionProviderWrapper }),
+ appConfigContext,
+ sessionContext,
+ };
+}
diff --git a/frontend/src/components/HiddenParticipantsTile/HiddenParticipantsTile.tsx b/frontend/src/components/HiddenParticipantsTile/HiddenParticipantsTile.tsx
index 7854bae5..c0864e73 100644
--- a/frontend/src/components/HiddenParticipantsTile/HiddenParticipantsTile.tsx
+++ b/frontend/src/components/HiddenParticipantsTile/HiddenParticipantsTile.tsx
@@ -1,11 +1,11 @@
import { ReactElement } from 'react';
import { AvatarGroup } from '@mui/material';
import { Box } from 'opentok-layout-js';
-import { SubscriberWrapper } from '../../types/session';
+import { SubscriberWrapper } from '@app-types/session';
+import getBoxStyle from '@utils/helpers/getBoxStyle';
+import useSessionContext from '@hooks/useSessionContext';
+import useShouldShowParticipantList from '@Context/AppConfig/hooks/useShouldShowParticipantList';
import AvatarInitials from '../AvatarInitials';
-import getBoxStyle from '../../utils/helpers/getBoxStyle';
-import useSessionContext from '../../hooks/useSessionContext';
-import useConfigContext from '../../hooks/useConfigContext';
export type HiddenParticipantsTileProps = {
box: Box;
@@ -26,8 +26,9 @@ const HiddenParticipantsTile = ({
hiddenSubscribers,
}: HiddenParticipantsTileProps): ReactElement => {
const { toggleParticipantList } = useSessionContext();
- const config = useConfigContext();
- const { showParticipantList } = config.meetingRoomSettings;
+
+ const showParticipantList = useShouldShowParticipantList();
+
const { height, width } = box;
const diameter = Math.min(height, width) * 0.38;
return (
diff --git a/frontend/src/components/MeetingRoom/ArchivingButton/ArchivingButton.spec.tsx b/frontend/src/components/MeetingRoom/ArchivingButton/ArchivingButton.spec.tsx
index 56189cfc..070d4bae 100644
--- a/frontend/src/components/MeetingRoom/ArchivingButton/ArchivingButton.spec.tsx
+++ b/frontend/src/components/MeetingRoom/ArchivingButton/ArchivingButton.spec.tsx
@@ -1,18 +1,20 @@
import { describe, expect, it, vi, beforeEach, Mock } from 'vitest';
-import { render, screen, act, waitFor } from '@testing-library/react';
-import { startArchiving, stopArchiving } from '../../../api/archiving';
+import { render as renderBase, screen, act, waitFor } from '@testing-library/react';
+import { ReactElement } from 'react';
+import { startArchiving, stopArchiving } from '@api/archiving';
+import useRoomName from '@hooks/useRoomName';
+import useSessionContext from '@hooks/useSessionContext';
+import { SessionContextType } from '@Context/SessionProvider/session';
+import {
+ type AppConfigProviderWrapperOptions,
+ makeAppConfigProviderWrapper,
+} from '@test/providers';
import ArchivingButton from './ArchivingButton';
-import useRoomName from '../../../hooks/useRoomName';
-import useSessionContext from '../../../hooks/useSessionContext';
-import { SessionContextType } from '../../../Context/SessionProvider/session';
-import useConfigContext from '../../../hooks/useConfigContext';
-import { ConfigContextType } from '../../../Context/ConfigProvider';
-vi.mock('../../../hooks/useSessionContext');
-vi.mock('../../../hooks/useRoomName');
-vi.mock('../../../hooks/useConfigContext');
+vi.mock('@hooks/useSessionContext');
+vi.mock('@hooks/useRoomName');
-vi.mock('../../../api/archiving', () => ({
+vi.mock('@api/archiving', () => ({
startArchiving: vi.fn(),
stopArchiving: vi.fn(),
}));
@@ -21,10 +23,8 @@ describe('ArchivingButton', () => {
const mockHandleCloseMenu = vi.fn();
const mockedRoomName = 'test-room-name';
let sessionContext: SessionContextType;
- let configContext: ConfigContextType;
const mockUseSessionContext = useSessionContext as Mock<[], SessionContextType>;
- const mockUseConfigContext = useConfigContext as Mock<[], ConfigContextType>;
const testArchiveId = 'test-archive-id';
beforeEach(() => {
@@ -34,14 +34,8 @@ describe('ArchivingButton', () => {
subscriberWrappers: [],
archiveId: null,
} as unknown as SessionContextType;
- configContext = {
- meetingRoomSettings: {
- allowArchiving: true,
- },
- } as Partial as ConfigContextType;
mockUseSessionContext.mockReturnValue(sessionContext as unknown as SessionContextType);
- mockUseConfigContext.mockReturnValue(configContext);
});
it('renders the button correctly', () => {
@@ -99,13 +93,27 @@ describe('ArchivingButton', () => {
});
it('is not rendered when allowArchiving is disabled', () => {
- mockUseConfigContext.mockReturnValue({
- meetingRoomSettings: {
- allowArchiving: false,
+ render(, {
+ appConfigOptions: {
+ value: {
+ meetingRoomSettings: {
+ allowArchiving: false,
+ },
+ },
},
- } as Partial as ConfigContextType);
+ });
- render();
expect(screen.queryByTestId('archiving-button')).not.toBeInTheDocument();
});
});
+
+function render(
+ ui: ReactElement,
+ options?: {
+ appConfigOptions?: AppConfigProviderWrapperOptions;
+ }
+) {
+ const { AppConfigWrapper } = makeAppConfigProviderWrapper(options?.appConfigOptions);
+
+ return renderBase(ui, { ...options, wrapper: AppConfigWrapper });
+}
diff --git a/frontend/src/components/MeetingRoom/ArchivingButton/ArchivingButton.tsx b/frontend/src/components/MeetingRoom/ArchivingButton/ArchivingButton.tsx
index ea10cc71..82d4e391 100644
--- a/frontend/src/components/MeetingRoom/ArchivingButton/ArchivingButton.tsx
+++ b/frontend/src/components/MeetingRoom/ArchivingButton/ArchivingButton.tsx
@@ -2,12 +2,12 @@ import RadioButtonCheckedIcon from '@mui/icons-material/RadioButtonChecked';
import { Tooltip } from '@mui/material';
import { ReactElement, useState } from 'react';
import { useTranslation } from 'react-i18next';
-import useRoomName from '../../../hooks/useRoomName';
+import useRoomName from '@hooks/useRoomName';
+import { startArchiving, stopArchiving } from '@api/archiving';
+import useSessionContext from '@hooks/useSessionContext';
+import useAppConfig from '@Context/AppConfig/hooks/useAppConfig';
import ToolbarButton from '../ToolbarButton';
import PopupDialog, { DialogTexts } from '../PopupDialog';
-import { startArchiving, stopArchiving } from '../../../api/archiving';
-import useSessionContext from '../../../hooks/useSessionContext';
-import useConfigContext from '../../../hooks/useConfigContext';
export type ArchivingButtonProps = {
isOverflowButton?: boolean;
@@ -32,14 +32,15 @@ const ArchivingButton = ({
const { t } = useTranslation();
const roomName = useRoomName();
const { archiveId } = useSessionContext();
- const config = useConfigContext();
+ const allowArchiving = useAppConfig(
+ ({ meetingRoomSettings }) => meetingRoomSettings.allowArchiving
+ );
const isRecording = !!archiveId;
const [isModalOpen, setIsModalOpen] = useState(false);
const title = isRecording ? t('recording.stop.title') : t('recording.start.title');
const handleButtonClick = () => {
setIsModalOpen((prev) => !prev);
};
- const { allowArchiving } = config.meetingRoomSettings;
const startRecordingText: DialogTexts = {
title: t('recording.start.dialog.title'),
diff --git a/frontend/src/components/MeetingRoom/CaptionsButton/CaptionsButton.spec.tsx b/frontend/src/components/MeetingRoom/CaptionsButton/CaptionsButton.spec.tsx
index c33e0aef..d4ce6753 100644
--- a/frontend/src/components/MeetingRoom/CaptionsButton/CaptionsButton.spec.tsx
+++ b/frontend/src/components/MeetingRoom/CaptionsButton/CaptionsButton.spec.tsx
@@ -1,26 +1,24 @@
import { describe, expect, it, vi, beforeEach, Mock } from 'vitest';
-import { render, screen, act, waitFor } from '@testing-library/react';
+import { render as renderBase, screen, act, waitFor } from '@testing-library/react';
import type { AxiosResponse } from 'axios';
import { Subscriber } from '@vonage/client-sdk-video';
-import { enableCaptions, disableCaptions } from '../../../api/captions';
+import { ReactElement } from 'react';
+import useRoomName from '@hooks/useRoomName';
+import { SessionContextType } from '@Context/SessionProvider/session';
+import useSessionContext from '@hooks/useSessionContext';
+import { SubscriberWrapper } from '@app-types/session';
+import { AppConfigProviderWrapperOptions, makeAppConfigProviderWrapper } from '@test/providers';
+import { enableCaptions, disableCaptions } from '@api/captions';
import CaptionsButton, { CaptionsState } from './CaptionsButton';
-import useRoomName from '../../../hooks/useRoomName';
-import { SessionContextType } from '../../../Context/SessionProvider/session';
-import useSessionContext from '../../../hooks/useSessionContext';
-import { SubscriberWrapper } from '../../../types/session';
-import useConfigContext from '../../../hooks/useConfigContext';
-import { ConfigContextType } from '../../../Context/ConfigProvider';
-vi.mock('../../../hooks/useSessionContext');
-vi.mock('../../../hooks/useRoomName');
-vi.mock('../../../api/captions', () => ({
+vi.mock('@hooks/useSessionContext');
+vi.mock('@hooks/useRoomName');
+vi.mock('@api/captions', () => ({
enableCaptions: vi.fn(),
disableCaptions: vi.fn(),
}));
-vi.mock('../../../hooks/useConfigContext');
const mockUseSessionContext = useSessionContext as Mock<[], SessionContextType>;
-const mockUseConfigContext = useConfigContext as Mock<[], ConfigContextType>;
describe('CaptionsButton', () => {
const mockHandleCloseMenu = vi.fn();
@@ -34,7 +32,6 @@ describe('CaptionsButton', () => {
} as CaptionsState;
const mockedRoomName = 'test-room-name';
let sessionContext: SessionContextType;
- let configContext: ConfigContextType;
const createSubscriberWrapper = (id: string): SubscriberWrapper => {
const mockSubscriber = {
@@ -69,13 +66,7 @@ describe('CaptionsButton', () => {
sessionContext = {
subscriberWrappers: [createSubscriberWrapper('subscriber-1')],
} as unknown as SessionContextType;
- configContext = {
- meetingRoomSettings: {
- allowCaptions: true,
- },
- } as Partial as ConfigContextType;
mockUseSessionContext.mockReturnValue(sessionContext as unknown as SessionContextType);
- mockUseConfigContext.mockReturnValue(configContext);
});
it('renders the button correctly', () => {
@@ -89,7 +80,16 @@ describe('CaptionsButton', () => {
});
it('turns the captions on when button is pressed', async () => {
- render();
+ render(, {
+ appConfigOptions: {
+ value: {
+ meetingRoomSettings: {
+ allowCaptions: true,
+ },
+ },
+ },
+ });
+
act(() => screen.getByTestId('captions-button').click());
await waitFor(() => {
@@ -98,12 +98,27 @@ describe('CaptionsButton', () => {
});
it('is not rendered when allowCaptions is false', () => {
- mockUseConfigContext.mockReturnValue({
- meetingRoomSettings: {
- allowCaptions: false,
+ render(, {
+ appConfigOptions: {
+ value: {
+ meetingRoomSettings: {
+ allowCaptions: false,
+ },
+ },
},
- } as Partial as ConfigContextType);
- render();
+ });
+
expect(screen.queryByTestId('captions-button')).not.toBeInTheDocument();
});
});
+
+function render(
+ ui: ReactElement,
+ options?: {
+ appConfigOptions?: AppConfigProviderWrapperOptions;
+ }
+) {
+ const { AppConfigWrapper } = makeAppConfigProviderWrapper(options?.appConfigOptions);
+
+ return renderBase(ui, { ...options, wrapper: AppConfigWrapper });
+}
diff --git a/frontend/src/components/MeetingRoom/CaptionsButton/CaptionsButton.tsx b/frontend/src/components/MeetingRoom/CaptionsButton/CaptionsButton.tsx
index c384526c..a748081f 100644
--- a/frontend/src/components/MeetingRoom/CaptionsButton/CaptionsButton.tsx
+++ b/frontend/src/components/MeetingRoom/CaptionsButton/CaptionsButton.tsx
@@ -3,10 +3,10 @@ import { Tooltip } from '@mui/material';
import { Dispatch, ReactElement, useState, SetStateAction } from 'react';
import { AxiosError } from 'axios';
import { useTranslation } from 'react-i18next';
-import useRoomName from '../../../hooks/useRoomName';
+import useIsMeetingCaptionsAllowed from '@Context/AppConfig/hooks/useIsMeetingCaptionsAllowed';
+import { disableCaptions, enableCaptions } from '@api/captions';
+import useRoomName from '@hooks/useRoomName';
import ToolbarButton from '../ToolbarButton';
-import { disableCaptions, enableCaptions } from '../../../api/captions';
-import useConfigContext from '../../../hooks/useConfigContext';
export type CaptionsState = {
isUserCaptionsEnabled: boolean;
@@ -35,14 +35,14 @@ const CaptionsButton = ({
handleClick,
captionsState,
}: CaptionsButtonProps): ReactElement | false => {
- const config = useConfigContext();
+ const isMeetingCaptionsAllowed = useIsMeetingCaptionsAllowed();
+
const { t } = useTranslation();
const roomName = useRoomName();
const [captionsId, setCaptionsId] = useState('');
const { isUserCaptionsEnabled, setIsUserCaptionsEnabled, setCaptionsErrorResponse } =
captionsState;
const title = isUserCaptionsEnabled ? t('captions.disable') : t('captions.enable');
- const { allowCaptions } = config.meetingRoomSettings;
const handleClose = () => {
if (isOverflowButton && handleClick) {
@@ -96,7 +96,7 @@ const CaptionsButton = ({
};
return (
- allowCaptions && (
+ isMeetingCaptionsAllowed && (
;
const sessionContext = {
unreadCount: 10,
} as unknown as SessionContextType;
-const mockConfigContext = {
- meetingRoomSettings: {
- allowChat: true,
- },
-} as Partial as ConfigContextType;
-const mockUseConfigContext = useConfigContext as Mock<[], ConfigContextType>;
describe('ChatButton', () => {
beforeEach(() => {
mockUseSessionContext.mockReturnValue(sessionContext);
- mockUseConfigContext.mockReturnValue(mockConfigContext);
});
it('should show unread message number', () => {
@@ -63,12 +55,27 @@ describe('ChatButton', () => {
});
it('is not rendered when allowChat is false', () => {
- mockUseConfigContext.mockReturnValue({
- meetingRoomSettings: {
- allowChat: false,
+ render( {}} isOpen />, {
+ appConfigOptions: {
+ value: {
+ meetingRoomSettings: {
+ allowChat: false,
+ },
+ },
},
- } as Partial as ConfigContextType);
- render( {}} isOpen />);
+ });
+
expect(screen.queryByTestId('ChatIcon')).not.toBeInTheDocument();
});
});
+
+function render(
+ ui: ReactElement,
+ options?: {
+ appConfigOptions?: AppConfigProviderWrapperOptions;
+ }
+) {
+ const { AppConfigWrapper } = makeAppConfigProviderWrapper(options?.appConfigOptions);
+
+ return renderBase(ui, { ...options, wrapper: AppConfigWrapper });
+}
diff --git a/frontend/src/components/MeetingRoom/ChatButton/ChatButton.tsx b/frontend/src/components/MeetingRoom/ChatButton/ChatButton.tsx
index 01cf0a7c..4ad0f30d 100644
--- a/frontend/src/components/MeetingRoom/ChatButton/ChatButton.tsx
+++ b/frontend/src/components/MeetingRoom/ChatButton/ChatButton.tsx
@@ -3,9 +3,9 @@ import Tooltip from '@mui/material/Tooltip';
import { blue } from '@mui/material/colors';
import { ReactElement } from 'react';
import { useTranslation } from 'react-i18next';
+import useIsMeetingChatAllowed from '@Context/AppConfig/hooks/useIsMeetingChatAllowed';
import ToolbarButton from '../ToolbarButton';
import UnreadMessagesBadge from '../UnreadMessagesBadge';
-import useConfigContext from '../../../hooks/useConfigContext';
export type ChatButtonProps = {
handleClick: () => void;
@@ -29,12 +29,11 @@ const ChatButton = ({
isOpen,
isOverflowButton = false,
}: ChatButtonProps): ReactElement | false => {
- const config = useConfigContext();
- const { allowChat } = config.meetingRoomSettings;
+ const isMeetingChatAllowed = useIsMeetingChatAllowed();
const { t } = useTranslation();
return (
- allowChat && (
+ isMeetingChatAllowed && (
{
+vi.mock('@hooks/useBackgroundPublisherContext', () => {
return {
default: () => ({
toggleVideo: toggleBackgroundVideoPublisherMock,
}),
};
});
-vi.mock('../../../hooks/useConfigContext');
vi.mock('react-i18next', () => ({
useTranslation: () => ({
@@ -42,13 +41,11 @@ vi.mock('react-i18next', () => ({
const mockUsePublisherContext = usePublisherContext as Mock<[], PublisherContextType>;
const mockUseSpeakingDetector = useSpeakingDetector as Mock<[], boolean>;
const mockHandleToggleBackgroundEffects = vi.fn();
-const mockUseConfigContext = useConfigContext as Mock<[], ConfigContextType>;
describe('DeviceControlButton', () => {
const nativeMediaDevices = global.navigator.mediaDevices;
let mockPublisher: Publisher;
let publisherContext: PublisherContextType;
- let configContext: ConfigContextType;
beforeEach(() => {
mockPublisher = Object.assign(new EventEmitter(), {
@@ -82,16 +79,6 @@ describe('DeviceControlButton', () => {
removeEventListener: vi.fn(() => []),
},
});
-
- configContext = {
- audioSettings: {
- allowMicrophoneControl: true,
- },
- videoSettings: {
- allowCameraControl: true,
- },
- } as Partial as ConfigContextType;
- mockUseConfigContext.mockReturnValue(configContext);
});
afterEach(() => {
@@ -142,12 +129,23 @@ describe('DeviceControlButton', () => {
});
it('renders the button as disabled with greyed out icon and correct tooltip when allowMicrophoneControl is false', async () => {
- configContext.audioSettings.allowMicrophoneControl = false;
render(
+ />,
+ {
+ appConfigOptions: {
+ value: {
+ audioSettings: {
+ allowMicrophoneControl: false,
+ },
+ videoSettings: {
+ allowCameraControl: true,
+ },
+ },
+ },
+ }
);
const micButton = screen.getByLabelText('Microphone');
expect(micButton).toBeInTheDocument();
@@ -178,12 +176,23 @@ describe('DeviceControlButton', () => {
});
it('renders the button as disabled with greyed out icon and correct tooltip when allowCameraControl is false', async () => {
- configContext.videoSettings.allowCameraControl = false;
render(
+ />,
+ {
+ appConfigOptions: {
+ value: {
+ audioSettings: {
+ allowMicrophoneControl: true,
+ },
+ videoSettings: {
+ allowCameraControl: false,
+ },
+ },
+ },
+ }
);
const videoButton = screen.getByLabelText('Camera');
@@ -198,3 +207,14 @@ describe('DeviceControlButton', () => {
});
});
});
+
+function render(
+ ui: ReactElement,
+ options?: {
+ appConfigOptions?: AppConfigProviderWrapperOptions;
+ }
+) {
+ const { AppConfigWrapper } = makeAppConfigProviderWrapper(options?.appConfigOptions);
+
+ return renderBase(ui, { ...options, wrapper: AppConfigWrapper });
+}
diff --git a/frontend/src/components/MeetingRoom/DeviceControlButton/DeviceControlButton.tsx b/frontend/src/components/MeetingRoom/DeviceControlButton/DeviceControlButton.tsx
index e862edc9..f46bb879 100644
--- a/frontend/src/components/MeetingRoom/DeviceControlButton/DeviceControlButton.tsx
+++ b/frontend/src/components/MeetingRoom/DeviceControlButton/DeviceControlButton.tsx
@@ -7,12 +7,13 @@ import ButtonGroup from '@mui/material/ButtonGroup';
import { MicOff, ArrowDropUp, ArrowDropDown } from '@mui/icons-material';
import { useState, useRef, useCallback, ReactElement } from 'react';
import { useTranslation } from 'react-i18next';
-import MutedAlert from '../../MutedAlert';
-import usePublisherContext from '../../../hooks/usePublisherContext';
+import useIsMicrophoneControlAllowed from '@Context/AppConfig/hooks/useIsMicrophoneControlAllowed';
+import useIsCameraControlAllowed from '@Context/AppConfig/hooks/useIsCameraControlAllowed';
+import usePublisherContext from '@hooks/usePublisherContext';
+import useBackgroundPublisherContext from '@hooks/useBackgroundPublisherContext';
+import getControlButtonTooltip from '@utils/getControlButtonTooltip';
import DeviceSettingsMenu from '../DeviceSettingsMenu';
-import useBackgroundPublisherContext from '../../../hooks/useBackgroundPublisherContext';
-import useConfigContext from '../../../hooks/useConfigContext';
-import getControlButtonTooltip from '../../../utils/getControlButtonTooltip';
+import MutedAlert from '../../MutedAlert';
import { colors } from '../../../utils/customTheme/customTheme';
export type DeviceControlButtonProps = {
@@ -37,20 +38,21 @@ const DeviceControlButton = ({
const { t } = useTranslation();
const { isVideoEnabled, toggleAudio, toggleVideo, isAudioEnabled } = usePublisherContext();
const { toggleVideo: toggleBackgroundVideoPublisher } = useBackgroundPublisherContext();
- const config = useConfigContext();
+
+ const isMicrophoneControlAllowed = useIsMicrophoneControlAllowed();
+ const isCameraControlAllowed = useIsCameraControlAllowed();
+
const isAudio = deviceType === 'audio';
const [open, setOpen] = useState(false);
const anchorRef = useRef(null);
- const { allowMicrophoneControl } = config.audioSettings;
- const { allowCameraControl } = config.videoSettings;
- const isButtonDisabled = isAudio ? !allowMicrophoneControl : !allowCameraControl;
+ const isButtonDisabled = isAudio ? !isMicrophoneControlAllowed : !isCameraControlAllowed;
const tooltipTitle = getControlButtonTooltip({
isAudio,
isAudioEnabled,
isVideoEnabled,
- allowMicrophoneControl,
- allowCameraControl,
+ allowMicrophoneControl: isMicrophoneControlAllowed,
+ allowCameraControl: isCameraControlAllowed,
t,
});
@@ -67,7 +69,7 @@ const DeviceControlButton = ({
const renderControlIcon = () => {
if (isAudio) {
- if (!allowMicrophoneControl) {
+ if (!isMicrophoneControlAllowed) {
return ;
}
if (isAudioEnabled) {
@@ -76,7 +78,7 @@ const DeviceControlButton = ({
return ;
}
- if (!allowCameraControl) {
+ if (!isCameraControlAllowed) {
return ;
}
if (isVideoEnabled) {
diff --git a/frontend/src/components/MeetingRoom/DeviceSettingsMenu/DeviceSettingsMenu.spec.tsx b/frontend/src/components/MeetingRoom/DeviceSettingsMenu/DeviceSettingsMenu.spec.tsx
index 6aee4ad1..0576d041 100644
--- a/frontend/src/components/MeetingRoom/DeviceSettingsMenu/DeviceSettingsMenu.spec.tsx
+++ b/frontend/src/components/MeetingRoom/DeviceSettingsMenu/DeviceSettingsMenu.spec.tsx
@@ -1,19 +1,25 @@
-import { act, fireEvent, queryByText, render, screen, waitFor } from '@testing-library/react';
+import {
+ act,
+ fireEvent,
+ queryByText,
+ render as renderBase,
+ screen,
+ waitFor,
+} from '@testing-library/react';
import { describe, beforeEach, it, Mock, vi, expect, afterAll } from 'vitest';
-import { RefObject } from 'react';
+import { ReactElement, RefObject } from 'react';
import { EventEmitter } from 'stream';
import { hasMediaProcessorSupport } from '@vonage/client-sdk-video';
-import * as util from '../../../utils/util';
-import DeviceSettingsMenu from './DeviceSettingsMenu';
-import { AudioOutputProvider } from '../../../Context/AudioOutputProvider';
+import * as util from '@utils/util';
+import { AudioOutputProvider } from '@Context/AudioOutputProvider';
import {
audioInputDevices,
audioOutputDevices,
nativeDevices,
videoInputDevices,
-} from '../../../utils/mockData/device';
-import useConfigContext from '../../../hooks/useConfigContext';
-import { ConfigContextType } from '../../../Context/ConfigProvider';
+} from '@utils/mockData/device';
+import { AppConfigProviderWrapperOptions, makeAppConfigProviderWrapper } from '@test/providers';
+import DeviceSettingsMenu from './DeviceSettingsMenu';
const {
mockHasMediaProcessorSupport,
@@ -38,17 +44,14 @@ vi.mock('@vonage/client-sdk-video', () => ({
setAudioOutputDevice: mockSetAudioOutputDevice,
}));
-vi.mock('../../../utils/util', async () => {
- const actual = await vi.importActual('../../../utils/util');
+vi.mock('@utils/util', async () => {
+ const actual = await vi.importActual('@utils/util');
return {
...actual,
isGetActiveAudioOutputDeviceSupported: vi.fn(),
};
});
-vi.mock('../../../hooks/useConfigContext');
-const mockUseConfigContext = useConfigContext as Mock<[], ConfigContextType>;
-
// This is returned by Vonage SDK if audioOutput is not supported
const vonageDefaultEmptyOutputDevice = { deviceId: null, label: null };
@@ -63,7 +66,6 @@ describe('DeviceSettingsMenu Component', () => {
const mockHandleClose = vi.fn();
let deviceChangeListener: EventEmitter;
const mockedHasMediaProcessorSupport = vi.fn();
- let configContext: ConfigContextType;
beforeEach(() => {
vi.resetAllMocks();
@@ -88,18 +90,6 @@ describe('DeviceSettingsMenu Component', () => {
});
(hasMediaProcessorSupport as Mock).mockImplementation(mockedHasMediaProcessorSupport);
mockedHasMediaProcessorSupport.mockReturnValue(false);
- configContext = {
- audioSettings: {
- allowAdvancedNoiseSuppression: true,
- },
- videoSettings: {
- allowBackgroundEffects: true,
- },
- meetingRoomSettings: {
- allowDeviceSelection: true,
- },
- } as Partial as ConfigContextType;
- mockUseConfigContext.mockReturnValue(configContext);
});
afterAll(() => {
@@ -370,16 +360,6 @@ describe('DeviceSettingsMenu Component', () => {
});
it('and does not render the dropdown separator and background effects option when allowBackgroundEffects is false', async () => {
- configContext = {
- videoSettings: {
- allowBackgroundEffects: false,
- },
- meetingRoomSettings: {
- allowDeviceSelection: true,
- },
- } as Partial as ConfigContextType;
- mockUseConfigContext.mockReturnValue(configContext);
-
render(
{
isOpen
anchorRef={mockAnchorRef}
setIsOpen={mockSetIsOpen}
- />
+ />,
+ {
+ appConfigOptions: {
+ value: {
+ videoSettings: {
+ allowBackgroundEffects: false,
+ },
+ meetingRoomSettings: {
+ allowDeviceSelection: true,
+ },
+ },
+ },
+ }
);
await waitFor(() => {
@@ -399,3 +391,14 @@ describe('DeviceSettingsMenu Component', () => {
});
});
});
+
+function render(
+ ui: ReactElement,
+ options?: {
+ appConfigOptions?: AppConfigProviderWrapperOptions;
+ }
+) {
+ const { AppConfigWrapper } = makeAppConfigProviderWrapper(options?.appConfigOptions);
+
+ return renderBase(ui, { wrapper: AppConfigWrapper });
+}
diff --git a/frontend/src/components/MeetingRoom/DeviceSettingsMenu/DeviceSettingsMenu.tsx b/frontend/src/components/MeetingRoom/DeviceSettingsMenu/DeviceSettingsMenu.tsx
index 9f3d822f..636d654a 100644
--- a/frontend/src/components/MeetingRoom/DeviceSettingsMenu/DeviceSettingsMenu.tsx
+++ b/frontend/src/components/MeetingRoom/DeviceSettingsMenu/DeviceSettingsMenu.tsx
@@ -6,6 +6,7 @@ import { useTheme } from '@mui/material/styles';
import { ReactElement, RefObject, Dispatch, SetStateAction } from 'react';
import { PopperChildrenProps } from '@mui/base';
import { hasMediaProcessorSupport } from '@vonage/client-sdk-video';
+import useAppConfig from '@Context/AppConfig/hooks/useAppConfig';
import InputDevices from '../InputDevices';
import OutputDevices from '../OutputDevices';
import ReduceNoiseTestSpeakers from '../ReduceNoiseTestSpeakers';
@@ -13,7 +14,6 @@ import useDropdownResizeObserver from '../../../hooks/useDropdownResizeObserver'
import VideoDevices from '../VideoDevices';
import DropdownSeparator from '../DropdownSeparator';
import VideoDevicesOptions from '../VideoDevicesOptions';
-import useConfigContext from '../../../hooks/useConfigContext';
import { colors } from '../../../utils/customTheme/customTheme';
export type DeviceSettingsMenuProps = {
@@ -53,10 +53,12 @@ const DeviceSettingsMenu = ({
handleClose,
setIsOpen,
}: DeviceSettingsMenuProps): ReactElement | false => {
- const config = useConfigContext();
+ const allowBackgroundEffects = useAppConfig(
+ ({ videoSettings }) => videoSettings.allowBackgroundEffects
+ );
+
const isAudio = deviceType === 'audio';
const theme = useTheme();
- const { allowBackgroundEffects } = config.videoSettings;
const shouldDisplayBackgroundEffects = hasMediaProcessorSupport() && allowBackgroundEffects;
const handleToggleBackgroundEffects = () => {
diff --git a/frontend/src/components/MeetingRoom/EmojiGridButton/EmojiGridButton.spec.tsx b/frontend/src/components/MeetingRoom/EmojiGridButton/EmojiGridButton.spec.tsx
index f1050bb6..b8c7c79a 100644
--- a/frontend/src/components/MeetingRoom/EmojiGridButton/EmojiGridButton.spec.tsx
+++ b/frontend/src/components/MeetingRoom/EmojiGridButton/EmojiGridButton.spec.tsx
@@ -1,10 +1,9 @@
-import { act, render, screen } from '@testing-library/react';
-import { afterEach, beforeEach, describe, expect, it, vi, Mock } from 'vitest';
-import { useState } from 'react';
+import { act, render as renderBase, screen } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi, Mock } from 'vitest';
+import { ReactElement, useState } from 'react';
import * as mui from '@mui/material';
+import { AppConfigProviderWrapperOptions, makeAppConfigProviderWrapper } from '@test/providers';
import EmojiGridButton from './EmojiGridButton';
-import useConfigContext from '../../../hooks/useConfigContext';
-import { ConfigContextType } from '../../../Context/ConfigProvider';
vi.mock('@mui/material', async () => {
const actual = await vi.importActual('@mui/material');
@@ -13,12 +12,9 @@ vi.mock('@mui/material', async () => {
useMediaQuery: vi.fn(),
};
});
-vi.mock('../../../utils/emojis', () => ({
+vi.mock('@utils/emojis', () => ({
default: { FAVORITE: '🦧' },
}));
-vi.mock('../../../hooks/useConfigContext');
-
-const mockUseConfigContext = useConfigContext as Mock<[], ConfigContextType>;
const TestComponent = ({ defaultOpenEmojiGrid = false }: { defaultOpenEmojiGrid?: boolean }) => {
const [isEmojiGridOpen, setIsEmojiGridOpen] = useState(defaultOpenEmojiGrid);
@@ -32,20 +28,8 @@ const TestComponent = ({ defaultOpenEmojiGrid = false }: { defaultOpenEmojiGrid?
};
describe('EmojiGridButton', () => {
- let mockConfigContext: ConfigContextType;
-
beforeEach(() => {
(mui.useMediaQuery as Mock).mockReturnValue(false);
- mockConfigContext = {
- meetingRoomSettings: {
- allowEmojis: true,
- },
- } as Partial as ConfigContextType;
- mockUseConfigContext.mockReturnValue(mockConfigContext);
- });
-
- afterEach(() => {
- vi.resetAllMocks();
});
it('renders', () => {
@@ -67,8 +51,25 @@ describe('EmojiGridButton', () => {
});
it('is not rendered when allowEmojis is false', () => {
- mockConfigContext.meetingRoomSettings.allowEmojis = false;
- render();
+ render(, {
+ appConfigOptions: {
+ value: {
+ meetingRoomSettings: { allowEmojis: false },
+ },
+ },
+ });
+
expect(screen.queryByTestId('emoji-grid-button')).not.toBeInTheDocument();
});
});
+
+function render(
+ ui: ReactElement,
+ options?: {
+ appConfigOptions?: AppConfigProviderWrapperOptions;
+ }
+) {
+ const { AppConfigWrapper } = makeAppConfigProviderWrapper(options?.appConfigOptions);
+
+ return renderBase(ui, { ...options, wrapper: AppConfigWrapper });
+}
diff --git a/frontend/src/components/MeetingRoom/EmojiGridButton/EmojiGridButton.tsx b/frontend/src/components/MeetingRoom/EmojiGridButton/EmojiGridButton.tsx
index 8680642e..a43e4be4 100644
--- a/frontend/src/components/MeetingRoom/EmojiGridButton/EmojiGridButton.tsx
+++ b/frontend/src/components/MeetingRoom/EmojiGridButton/EmojiGridButton.tsx
@@ -2,9 +2,9 @@ import { Tooltip } from '@mui/material';
import { EmojiEmotions } from '@mui/icons-material';
import { Dispatch, ReactElement, SetStateAction, useRef } from 'react';
import { useTranslation } from 'react-i18next';
+import useAppConfig from '@Context/AppConfig/hooks/useAppConfig';
import ToolbarButton from '../ToolbarButton';
import EmojiGrid from '../EmojiGrid/EmojiGrid';
-import useConfigContext from '../../../hooks/useConfigContext';
import { colors } from '../../../utils/customTheme/customTheme';
export type EmojiGridProps = {
@@ -31,13 +31,13 @@ const EmojiGridButton = ({
isParentOpen,
isOverflowButton = false,
}: EmojiGridProps): ReactElement | false => {
- const { meetingRoomSettings } = useConfigContext();
+ const allowEmojis = useAppConfig(({ meetingRoomSettings }) => meetingRoomSettings.allowEmojis);
+
const { t } = useTranslation();
const anchorRef = useRef(null);
const handleToggle = () => {
setIsEmojiGridOpen((prevOpen) => !prevOpen);
};
- const { allowEmojis } = meetingRoomSettings;
return (
allowEmojis && (
diff --git a/frontend/src/components/MeetingRoom/InputDevices/InputDevices.spec.tsx b/frontend/src/components/MeetingRoom/InputDevices/InputDevices.spec.tsx
index 905feaa1..ff7cc304 100644
--- a/frontend/src/components/MeetingRoom/InputDevices/InputDevices.spec.tsx
+++ b/frontend/src/components/MeetingRoom/InputDevices/InputDevices.spec.tsx
@@ -1,21 +1,20 @@
import { describe, it, beforeEach, afterEach, vi, expect, Mock } from 'vitest';
-import { render, screen, fireEvent, cleanup } from '@testing-library/react';
+import { render as renderBase, screen, fireEvent, cleanup } from '@testing-library/react';
import { Publisher } from '@vonage/client-sdk-video';
import { EventEmitter } from 'stream';
+import { ReactElement } from 'react';
+import { AppConfigProviderWrapperOptions, makeAppConfigProviderWrapper } from '@test/providers';
+import useDevices from '@hooks/useDevices';
+import usePublisherContext from '@hooks/usePublisherContext';
+import { AllMediaDevices } from '@app-types/room';
+import { PublisherContextType } from '@Context/PublisherProvider';
+import { allMediaDevices, defaultAudioDevice } from '@utils/mockData/device';
import InputDevices from './InputDevices';
-import useDevices from '../../../hooks/useDevices';
-import usePublisherContext from '../../../hooks/usePublisherContext';
-import useConfigContext from '../../../hooks/useConfigContext';
-import { AllMediaDevices } from '../../../types';
-import { PublisherContextType } from '../../../Context/PublisherProvider';
-import { ConfigContextType } from '../../../Context/ConfigProvider';
-import { allMediaDevices, defaultAudioDevice } from '../../../utils/mockData/device';
// Mocks
-vi.mock('../../../hooks/useDevices');
-vi.mock('../../../hooks/usePublisherContext');
-vi.mock('../../../hooks/useConfigContext');
-vi.mock('../../../utils/storage', () => ({
+vi.mock('@hooks/useDevices');
+vi.mock('@hooks/usePublisherContext');
+vi.mock('@utils/storage', () => ({
setStorageItem: vi.fn(),
STORAGE_KEYS: {
AUDIO_SOURCE: 'audioSource',
@@ -27,7 +26,6 @@ const mockUseDevices = useDevices as Mock<
{ allMediaDevices: AllMediaDevices; getAllMediaDevices: () => void }
>;
const mockUsePublisherContext = usePublisherContext as Mock<[], PublisherContextType>;
-const mockUseConfigContext = useConfigContext as Mock<[], ConfigContextType>;
describe('InputDevices Component', () => {
const mockHandleToggle = vi.fn();
@@ -35,7 +33,6 @@ describe('InputDevices Component', () => {
const mockGetAudioSource = vi.fn();
let mockPublisher: Publisher;
let publisherContext: PublisherContextType;
- let mockConfigContext: ConfigContextType;
beforeEach(() => {
mockGetAudioSource.mockReturnValue(defaultAudioDevice);
@@ -58,14 +55,7 @@ describe('InputDevices Component', () => {
initializeLocalPublisher: vi.fn(),
} as unknown as PublisherContextType;
- mockConfigContext = {
- meetingRoomSettings: {
- allowDeviceSelection: true,
- },
- } as Partial as ConfigContextType;
-
mockUsePublisherContext.mockImplementation(() => publisherContext);
- mockUseConfigContext.mockReturnValue(mockConfigContext);
});
afterEach(() => {
@@ -128,10 +118,15 @@ describe('InputDevices Component', () => {
});
it('is not rendered when allowDeviceSelection is false', () => {
- mockConfigContext.meetingRoomSettings.allowDeviceSelection = false;
- mockUseConfigContext.mockReturnValue(mockConfigContext);
-
- render();
+ render(, {
+ appConfigOptions: {
+ value: {
+ meetingRoomSettings: {
+ allowDeviceSelection: false,
+ },
+ },
+ },
+ });
expect(screen.queryByText('Microphone')).not.toBeInTheDocument();
});
@@ -148,3 +143,14 @@ describe('InputDevices Component', () => {
);
});
});
+
+function render(
+ ui: ReactElement,
+ options?: {
+ appConfigOptions?: AppConfigProviderWrapperOptions;
+ }
+) {
+ const { AppConfigWrapper } = makeAppConfigProviderWrapper(options?.appConfigOptions);
+
+ return renderBase(ui, { ...options, wrapper: AppConfigWrapper });
+}
diff --git a/frontend/src/components/MeetingRoom/InputDevices/InputDevices.tsx b/frontend/src/components/MeetingRoom/InputDevices/InputDevices.tsx
index 8798c6de..924f84bc 100644
--- a/frontend/src/components/MeetingRoom/InputDevices/InputDevices.tsx
+++ b/frontend/src/components/MeetingRoom/InputDevices/InputDevices.tsx
@@ -4,10 +4,10 @@ import { Device } from '@vonage/client-sdk-video';
import MicNoneIcon from '@mui/icons-material/MicNone';
import { MouseEvent as ReactMouseEvent, ReactElement, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
+import useAppConfig from '@Context/AppConfig/hooks/useAppConfig';
import useDevices from '../../../hooks/useDevices';
import usePublisherContext from '../../../hooks/usePublisherContext';
import { setStorageItem, STORAGE_KEYS } from '../../../utils/storage';
-import useConfigContext from '../../../hooks/useConfigContext';
import cleanAndDedupeDeviceLabels from '../../../utils/cleanAndDedupeDeviceLabels';
import { colors } from '../../../utils/customTheme/customTheme';
@@ -26,11 +26,14 @@ export type InputDevicesProps = {
const InputDevices = ({ handleToggle }: InputDevicesProps): ReactElement | false => {
const { t } = useTranslation();
const { publisher } = usePublisherContext();
- const { meetingRoomSettings } = useConfigContext();
+
+ const allowDeviceSelection = useAppConfig(
+ ({ meetingRoomSettings }) => meetingRoomSettings.allowDeviceSelection
+ );
+
const {
allMediaDevices: { audioInputDevices },
} = useDevices();
- const { allowDeviceSelection } = meetingRoomSettings;
const audioInputDevicesCleaned = useMemo(
() => cleanAndDedupeDeviceLabels(audioInputDevices),
diff --git a/frontend/src/components/MeetingRoom/OutputDevices/OutputDevices.spec.tsx b/frontend/src/components/MeetingRoom/OutputDevices/OutputDevices.spec.tsx
index 679c5ddc..fb9f1f90 100644
--- a/frontend/src/components/MeetingRoom/OutputDevices/OutputDevices.spec.tsx
+++ b/frontend/src/components/MeetingRoom/OutputDevices/OutputDevices.spec.tsx
@@ -1,35 +1,29 @@
import { describe, it, beforeEach, afterEach, vi, expect, Mock } from 'vitest';
-import { render, screen, fireEvent, cleanup } from '@testing-library/react';
+import { render as renderBase, screen, fireEvent, cleanup } from '@testing-library/react';
+import { ReactElement } from 'react';
+import useDevices from '@hooks/useDevices';
+import useAudioOutputContext from '@hooks/useAudioOutputContext';
+import { AudioOutputContextType } from '@Context/AudioOutputProvider';
+import { allMediaDevices } from '@utils/mockData/device';
+import * as util from '@utils/util';
+import { AllMediaDevices } from '@app-types/room';
+import { AppConfigProviderWrapperOptions, makeAppConfigProviderWrapper } from '@test/providers';
import OutputDevices from './OutputDevices';
-import useDevices from '../../../hooks/useDevices';
-import useAudioOutputContext from '../../../hooks/useAudioOutputContext';
-import useConfigContext from '../../../hooks/useConfigContext';
-import { AllMediaDevices } from '../../../types';
-import { AudioOutputContextType } from '../../../Context/AudioOutputProvider';
-import { ConfigContextType } from '../../../Context/ConfigProvider';
-import { allMediaDevices } from '../../../utils/mockData/device';
-import * as util from '../../../utils/util';
// Mocks
-vi.mock('../../../hooks/useDevices');
-vi.mock('../../../hooks/useAudioOutputContext');
-vi.mock('../../../hooks/useConfigContext');
-vi.mock('../../../utils/util', () => ({
- isGetActiveAudioOutputDeviceSupported: vi.fn(),
-}));
+vi.mock('@hooks/useDevices');
+vi.mock('@hooks/useAudioOutputContext');
const mockUseDevices = useDevices as Mock<
[],
{ allMediaDevices: AllMediaDevices; getAllMediaDevices: () => void }
>;
const mockUseAudioOutputContext = useAudioOutputContext as Mock<[], AudioOutputContextType>;
-const mockUseConfigContext = useConfigContext as Mock<[], ConfigContextType>;
describe('OutputDevices Component', () => {
const mockHandleToggle = vi.fn();
const mockSetAudioOutputDevice = vi.fn();
let audioOutputContext: AudioOutputContextType;
- let mockConfigContext: ConfigContextType;
beforeEach(() => {
mockUseDevices.mockReturnValue({
@@ -42,15 +36,9 @@ describe('OutputDevices Component', () => {
setAudioOutputDevice: mockSetAudioOutputDevice,
} as AudioOutputContextType;
- mockConfigContext = {
- meetingRoomSettings: {
- allowDeviceSelection: true,
- },
- } as Partial as ConfigContextType;
-
mockUseAudioOutputContext.mockImplementation(() => audioOutputContext);
- mockUseConfigContext.mockReturnValue(mockConfigContext);
- (util.isGetActiveAudioOutputDeviceSupported as Mock).mockReturnValue(true);
+
+ vi.spyOn(util, 'isGetActiveAudioOutputDeviceSupported').mockReturnValue(true);
});
afterEach(() => {
@@ -125,12 +113,28 @@ describe('OutputDevices Component', () => {
});
it('is not rendered when allowDeviceSelection is false', () => {
- mockConfigContext.meetingRoomSettings.allowDeviceSelection = false;
- mockUseConfigContext.mockReturnValue(mockConfigContext);
-
- render();
+ render(, {
+ appConfigOptions: {
+ value: {
+ meetingRoomSettings: {
+ allowDeviceSelection: false,
+ },
+ },
+ },
+ });
expect(screen.queryByTestId('output-device-title')).not.toBeInTheDocument();
expect(screen.queryByTestId('output-devices')).not.toBeInTheDocument();
});
});
+
+function render(
+ ui: ReactElement,
+ options?: {
+ appConfigOptions?: AppConfigProviderWrapperOptions;
+ }
+) {
+ const { AppConfigWrapper } = makeAppConfigProviderWrapper(options?.appConfigOptions);
+
+ return renderBase(ui, { ...options, wrapper: AppConfigWrapper });
+}
diff --git a/frontend/src/components/MeetingRoom/OutputDevices/OutputDevices.tsx b/frontend/src/components/MeetingRoom/OutputDevices/OutputDevices.tsx
index a7d20435..990436a6 100644
--- a/frontend/src/components/MeetingRoom/OutputDevices/OutputDevices.tsx
+++ b/frontend/src/components/MeetingRoom/OutputDevices/OutputDevices.tsx
@@ -4,12 +4,12 @@ import VolumeUpIcon from '@mui/icons-material/VolumeUp';
import { MouseEvent, ReactElement, useMemo } from 'react';
import type { AudioOutputDevice } from '@vonage/client-sdk-video';
import { useTranslation } from 'react-i18next';
-import useDevices from '../../../hooks/useDevices';
+import useAppConfig from '@Context/AppConfig/hooks/useAppConfig';
+import useDevices from '@hooks/useDevices';
+import useAudioOutputContext from '@hooks/useAudioOutputContext';
+import { isGetActiveAudioOutputDeviceSupported } from '@utils/util';
+import cleanAndDedupeDeviceLabels from '@utils/cleanAndDedupeDeviceLabels';
import DropdownSeparator from '../DropdownSeparator';
-import useAudioOutputContext from '../../../hooks/useAudioOutputContext';
-import { isGetActiveAudioOutputDeviceSupported } from '../../../utils/util';
-import useConfigContext from '../../../hooks/useConfigContext';
-import cleanAndDedupeDeviceLabels from '../../../utils/cleanAndDedupeDeviceLabels';
import { colors } from '../../../utils/customTheme/customTheme';
export type OutputDevicesProps = {
@@ -27,12 +27,15 @@ export type OutputDevicesProps = {
const OutputDevices = ({ handleToggle }: OutputDevicesProps): ReactElement | false => {
const { t } = useTranslation();
const { currentAudioOutputDevice, setAudioOutputDevice } = useAudioOutputContext();
- const { meetingRoomSettings } = useConfigContext();
+
+ const allowDeviceSelection = useAppConfig(
+ ({ meetingRoomSettings }) => meetingRoomSettings.allowDeviceSelection
+ );
+
const {
allMediaDevices: { audioOutputDevices },
} = useDevices();
const defaultOutputDevices = [{ deviceId: 'default', label: t('devices.audio.defaultLabel') }];
- const { allowDeviceSelection } = meetingRoomSettings;
const isAudioOutputSupported = isGetActiveAudioOutputDeviceSupported();
diff --git a/frontend/src/components/MeetingRoom/ParticipantListButton/ParticipantListButton.tsx b/frontend/src/components/MeetingRoom/ParticipantListButton/ParticipantListButton.tsx
index a37b159a..cf92da65 100644
--- a/frontend/src/components/MeetingRoom/ParticipantListButton/ParticipantListButton.tsx
+++ b/frontend/src/components/MeetingRoom/ParticipantListButton/ParticipantListButton.tsx
@@ -4,8 +4,8 @@ import { blue } from '@mui/material/colors';
import { Badge } from '@mui/material';
import { ReactElement } from 'react';
import { useTranslation } from 'react-i18next';
+import useShouldShowParticipantList from '@Context/AppConfig/hooks/useShouldShowParticipantList';
import ToolbarButton from '../ToolbarButton';
-import useConfigContext from '../../../hooks/useConfigContext';
export type ParticipantListButtonProps = {
handleClick: () => void;
@@ -31,8 +31,7 @@ const ParticipantListButton = ({
participantCount,
isOverflowButton = false,
}: ParticipantListButtonProps): ReactElement | false => {
- const { meetingRoomSettings } = useConfigContext();
- const { showParticipantList } = meetingRoomSettings;
+ const showParticipantList = useShouldShowParticipantList();
const { t } = useTranslation();
return (
diff --git a/frontend/src/components/MeetingRoom/ParticipantListButton/ParticipantsListButton.spec.tsx b/frontend/src/components/MeetingRoom/ParticipantListButton/ParticipantsListButton.spec.tsx
index 71c0b445..69cce648 100644
--- a/frontend/src/components/MeetingRoom/ParticipantListButton/ParticipantsListButton.spec.tsx
+++ b/frontend/src/components/MeetingRoom/ParticipantListButton/ParticipantsListButton.spec.tsx
@@ -1,22 +1,10 @@
-import { render, screen } from '@testing-library/react';
-import { describe, expect, it, Mock, vi, beforeEach } from 'vitest';
+import { render as renderBase, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import { ReactElement } from 'react';
+import { AppConfigProviderWrapperOptions, makeAppConfigProviderWrapper } from '@test/providers';
import ParticipantListButton from './ParticipantListButton';
-import useConfigContext from '../../../hooks/useConfigContext';
-import { ConfigContextType } from '../../../Context/ConfigProvider';
-
-vi.mock('../../../hooks/useConfigContext');
-
-const mockUseConfigContext = useConfigContext as Mock<[], ConfigContextType>;
describe('ParticipantListButton', () => {
- beforeEach(() => {
- mockUseConfigContext.mockReturnValue({
- meetingRoomSettings: {
- showParticipantList: true,
- },
- } as Partial as ConfigContextType);
- });
-
it('should show participant number', () => {
render( {}} isOpen={false} participantCount={10} />);
expect(screen.getByText('10')).toBeVisible();
@@ -36,12 +24,27 @@ describe('ParticipantListButton', () => {
expect(handleClick).toHaveBeenCalled();
});
it('is not rendered when showParticipantList is false', () => {
- mockUseConfigContext.mockReturnValue({
- meetingRoomSettings: {
- showParticipantList: false,
+ render( {}} isOpen={false} participantCount={10} />, {
+ appConfigOptions: {
+ value: {
+ meetingRoomSettings: {
+ showParticipantList: false,
+ },
+ },
},
- } as Partial as ConfigContextType);
- render( {}} isOpen={false} participantCount={10} />);
+ });
+
expect(screen.queryByTestId('participant-list-button')).not.toBeInTheDocument();
});
});
+
+function render(
+ ui: ReactElement,
+ options?: {
+ appConfigOptions?: AppConfigProviderWrapperOptions;
+ }
+) {
+ const { AppConfigWrapper } = makeAppConfigProviderWrapper(options?.appConfigOptions);
+
+ return renderBase(ui, { ...options, wrapper: AppConfigWrapper });
+}
diff --git a/frontend/src/components/MeetingRoom/ReduceNoiseTestSpeakers/ReduceNoiseTestSpeakers.spec.tsx b/frontend/src/components/MeetingRoom/ReduceNoiseTestSpeakers/ReduceNoiseTestSpeakers.spec.tsx
index 0fa37a77..b68c4016 100644
--- a/frontend/src/components/MeetingRoom/ReduceNoiseTestSpeakers/ReduceNoiseTestSpeakers.spec.tsx
+++ b/frontend/src/components/MeetingRoom/ReduceNoiseTestSpeakers/ReduceNoiseTestSpeakers.spec.tsx
@@ -1,19 +1,17 @@
import { describe, expect, it, vi, afterEach, Mock, beforeEach } from 'vitest';
-import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { fireEvent, render as renderBase, screen, waitFor } from '@testing-library/react';
import { EventEmitter } from 'stream';
import { Publisher } from '@vonage/client-sdk-video';
-import { defaultAudioDevice } from '../../../utils/mockData/device';
-import usePublisherContext from '../../../hooks/usePublisherContext';
+import { ReactElement } from 'react';
+import { defaultAudioDevice } from '@utils/mockData/device';
+import usePublisherContext from '@hooks/usePublisherContext';
+import { PublisherContextType } from '@Context/PublisherProvider';
+import { AppConfigProviderWrapperOptions, makeAppConfigProviderWrapper } from '@test/providers';
import ReduceNoiseTestSpeakers from './ReduceNoiseTestSpeakers';
-import { PublisherContextType } from '../../../Context/PublisherProvider';
-import useConfigContext from '../../../hooks/useConfigContext';
-import { ConfigContextType } from '../../../Context/ConfigProvider';
-vi.mock('../../../hooks/usePublisherContext');
-vi.mock('../../../hooks/useConfigContext');
+vi.mock('@hooks/usePublisherContext');
const mockUsePublisherContext = usePublisherContext as Mock<[], PublisherContextType>;
-const mockUseConfigContext = useConfigContext as Mock<[], ConfigContextType>;
const { mockHasMediaProcessorSupport } = vi.hoisted(() => {
return {
@@ -27,7 +25,6 @@ vi.mock('@vonage/client-sdk-video', () => ({
describe('ReduceNoiseTestSpeakers', () => {
let mockPublisher: Publisher;
let publisherContext: PublisherContextType;
- let configContext: ConfigContextType;
beforeEach(() => {
mockPublisher = Object.assign(new EventEmitter(), {
@@ -48,13 +45,8 @@ describe('ReduceNoiseTestSpeakers', () => {
publisherContext.publisher = mockPublisher;
}) as unknown as () => void,
} as unknown as PublisherContextType;
- configContext = {
- audioSettings: {
- allowAdvancedNoiseSuppression: true,
- },
- } as Partial as ConfigContextType;
+
mockUsePublisherContext.mockImplementation(() => publisherContext);
- mockUseConfigContext.mockReturnValue(configContext);
});
afterEach(() => {
@@ -137,15 +129,27 @@ describe('ReduceNoiseTestSpeakers', () => {
});
it('does not render the Advanced Noise Suppression option if allowAdvancedNoiseSuppression is false', () => {
- configContext = {
- audioSettings: {
- allowAdvancedNoiseSuppression: false,
+ render(, {
+ appConfigOptions: {
+ value: {
+ audioSettings: {
+ allowAdvancedNoiseSuppression: false,
+ },
+ },
},
- } as Partial as ConfigContextType;
- mockUseConfigContext.mockReturnValue(configContext);
-
- render();
+ });
expect(screen.queryByText('Advanced Noise Suppression')).not.toBeInTheDocument();
});
});
+
+function render(
+ ui: ReactElement,
+ options?: {
+ appConfigOptions?: AppConfigProviderWrapperOptions;
+ }
+) {
+ const { AppConfigWrapper } = makeAppConfigProviderWrapper(options?.appConfigOptions);
+
+ return renderBase(ui, { ...options, wrapper: AppConfigWrapper });
+}
diff --git a/frontend/src/components/MeetingRoom/ReduceNoiseTestSpeakers/ReduceNoiseTestSpeakers.tsx b/frontend/src/components/MeetingRoom/ReduceNoiseTestSpeakers/ReduceNoiseTestSpeakers.tsx
index 44ee9b1c..6af353dc 100644
--- a/frontend/src/components/MeetingRoom/ReduceNoiseTestSpeakers/ReduceNoiseTestSpeakers.tsx
+++ b/frontend/src/components/MeetingRoom/ReduceNoiseTestSpeakers/ReduceNoiseTestSpeakers.tsx
@@ -7,11 +7,11 @@ import HeadsetIcon from '@mui/icons-material/Headset';
import ToggleOffIcon from '@mui/icons-material/ToggleOff';
import ToggleOnIcon from '@mui/icons-material/ToggleOn';
import { useTranslation } from 'react-i18next';
-import usePublisherContext from '../../../hooks/usePublisherContext';
+import useAppConfig from '@Context/AppConfig/hooks/useAppConfig';
+import usePublisherContext from '@hooks/usePublisherContext';
+import { setStorageItem, STORAGE_KEYS } from '@utils/storage';
import DropdownSeparator from '../DropdownSeparator';
import SoundTest from '../../SoundTest';
-import { setStorageItem, STORAGE_KEYS } from '../../../utils/storage';
-import useConfigContext from '../../../hooks/useConfigContext';
import { colors } from '../../../utils/customTheme/customTheme';
/**
@@ -24,9 +24,12 @@ import { colors } from '../../../utils/customTheme/customTheme';
const ReduceNoiseTestSpeakers = (): ReactElement | false => {
const { t } = useTranslation();
const { publisher, isPublishing } = usePublisherContext();
- const config = useConfigContext();
+
+ const allowAdvancedNoiseSuppression = useAppConfig(
+ ({ audioSettings }) => audioSettings.allowAdvancedNoiseSuppression
+ );
+
const [isToggled, setIsToggled] = useState(false);
- const { allowAdvancedNoiseSuppression } = config.audioSettings;
const shouldDisplayANS = hasMediaProcessorSupport() && allowAdvancedNoiseSuppression;
const handleToggle = async () => {
diff --git a/frontend/src/components/MeetingRoom/Toolbar/Toolbar.spec.tsx b/frontend/src/components/MeetingRoom/Toolbar/Toolbar.spec.tsx
index 274f0281..296d6565 100644
--- a/frontend/src/components/MeetingRoom/Toolbar/Toolbar.spec.tsx
+++ b/frontend/src/components/MeetingRoom/Toolbar/Toolbar.spec.tsx
@@ -1,14 +1,16 @@
import { describe, expect, it, vi, beforeEach, Mock, afterAll } from 'vitest';
-import { render, screen } from '@testing-library/react';
+import { render as renderBase, screen } from '@testing-library/react';
import { useLocation } from 'react-router-dom';
-import useSpeakingDetector from '../../../hooks/useSpeakingDetector';
-import Toolbar, { ToolbarProps, CaptionsState } from './Toolbar';
-import isReportIssueEnabled from '../../../utils/isReportIssueEnabled';
+import { ReactElement } from 'react';
+import useSpeakingDetector from '@hooks/useSpeakingDetector';
+import isReportIssueEnabled from '@utils/isReportIssueEnabled';
import useToolbarButtons, {
UseToolbarButtons,
UseToolbarButtonsProps,
-} from '../../../hooks/useToolbarButtons';
-import { RIGHT_PANEL_BUTTON_COUNT } from '../../../utils/constants';
+} from '@hooks/useToolbarButtons';
+import { RIGHT_PANEL_BUTTON_COUNT } from '@utils/constants';
+import { makeAppConfigProviderWrapper } from '@test/providers';
+import Toolbar, { ToolbarProps, CaptionsState } from './Toolbar';
const mockedRoomName = { roomName: 'test-room-name' };
@@ -18,9 +20,9 @@ vi.mock('react-router-dom', () => ({
useParams: () => mockedRoomName,
}));
-vi.mock('../../../hooks/useSpeakingDetector');
-vi.mock('../../../utils/isReportIssueEnabled');
-vi.mock('../../../hooks/useToolbarButtons');
+vi.mock('@hooks/useSpeakingDetector');
+vi.mock('@utils/isReportIssueEnabled');
+vi.mock('@hooks/useToolbarButtons');
const mockUseSpeakingDetector = useSpeakingDetector as Mock<[], boolean>;
const mockIsReportIssueEnabled = isReportIssueEnabled as Mock<[], boolean>;
@@ -115,3 +117,9 @@ describe('Toolbar', () => {
expect(screen.queryByTestId('captions-button')).toBeVisible();
});
});
+
+function render(ui: ReactElement) {
+ const { AppConfigWrapper } = makeAppConfigProviderWrapper();
+
+ return renderBase(ui, { wrapper: AppConfigWrapper });
+}
diff --git a/frontend/src/components/MeetingRoom/Toolbar/Toolbar.tsx b/frontend/src/components/MeetingRoom/Toolbar/Toolbar.tsx
index cf844a35..5a8e4bc4 100644
--- a/frontend/src/components/MeetingRoom/Toolbar/Toolbar.tsx
+++ b/frontend/src/components/MeetingRoom/Toolbar/Toolbar.tsx
@@ -1,21 +1,21 @@
import { Dispatch, ReactElement, SetStateAction, useCallback, useRef, useState } from 'react';
+import useSessionContext from '@hooks/useSessionContext';
+import { RightPanelActiveTab } from '@hooks/useRightPanel';
+import isReportIssueEnabled from '@utils/isReportIssueEnabled';
+import useToolbarButtons from '@hooks/useToolbarButtons';
+import useBackgroundPublisherContext from '@hooks/useBackgroundPublisherContext';
import ScreenSharingButton from '../../ScreenSharingButton';
import TimeRoomNameMeetingRoom from '../TimeRoomName';
import ExitButton from '../ExitButton';
-import useSessionContext from '../../../hooks/useSessionContext';
import LayoutButton from '../LayoutButton';
import ParticipantListButton from '../ParticipantListButton';
import ArchivingButton from '../ArchivingButton';
import CaptionsButton from '../CaptionsButton';
import ChatButton from '../ChatButton';
-import { RightPanelActiveTab } from '../../../hooks/useRightPanel';
import ReportIssueButton from '../ReportIssueButton';
import ToolbarOverflowButton from '../ToolbarOverflowButton';
import EmojiGridButton from '../EmojiGridButton';
-import isReportIssueEnabled from '../../../utils/isReportIssueEnabled';
-import useToolbarButtons from '../../../hooks/useToolbarButtons';
import DeviceControlButton from '../DeviceControlButton';
-import useBackgroundPublisherContext from '../../../hooks/useBackgroundPublisherContext';
export type CaptionsState = {
isUserCaptionsEnabled: boolean;
diff --git a/frontend/src/components/MeetingRoom/ToolbarOverflowButton/ToolbarOverflowButton.spec.tsx b/frontend/src/components/MeetingRoom/ToolbarOverflowButton/ToolbarOverflowButton.spec.tsx
index efe73916..82ecaf25 100644
--- a/frontend/src/components/MeetingRoom/ToolbarOverflowButton/ToolbarOverflowButton.spec.tsx
+++ b/frontend/src/components/MeetingRoom/ToolbarOverflowButton/ToolbarOverflowButton.spec.tsx
@@ -1,18 +1,20 @@
import { beforeEach, describe, expect, it, Mock, vi } from 'vitest';
-import { act, render, screen } from '@testing-library/react';
-import useSessionContext from '../../../hooks/useSessionContext';
-import { SessionContextType } from '../../../Context/SessionProvider/session';
+import { act, render as renderBase, screen } from '@testing-library/react';
+import { ReactElement } from 'react';
+import useSessionContext from '@hooks/useSessionContext';
+import { SessionContextType } from '@Context/SessionProvider/session';
+import useUserContext from '@hooks/useUserContext';
+import { UserContextType } from '@Context/user';
+import { makeAppConfigProviderWrapper } from '@test/providers';
import ToolbarOverflowButton from './ToolbarOverflowButton';
-import useUserContext from '../../../hooks/useUserContext';
-import { UserContextType } from '../../../Context/user';
import {
ToolbarOverflowMenuProps,
CaptionsState,
} from '../ToolbarOverflowMenu/ToolbarOverflowMenu';
-vi.mock('../../../hooks/useSessionContext');
-vi.mock('../../../hooks/useUserContext');
-vi.mock('../../../hooks/useRoomName');
+vi.mock('@hooks/useSessionContext');
+vi.mock('@hooks/useUserContext');
+vi.mock('@hooks/useRoomName');
const mockUseSessionContext = useSessionContext as Mock<[], SessionContextType>;
const mockUseUserContext = useUserContext as Mock<[], UserContextType>;
const mockSetUser = vi.fn();
@@ -81,3 +83,9 @@ describe('ToolbarOverflowButton', () => {
expect(screen.queryAllByTestId('chat-button-unread-count').length).toBe(2);
});
});
+
+function render(ui: ReactElement) {
+ const { AppConfigWrapper } = makeAppConfigProviderWrapper();
+
+ return renderBase(ui, { wrapper: AppConfigWrapper });
+}
diff --git a/frontend/src/components/MeetingRoom/ToolbarOverflowMenu/ToolbarOverflowMenu.spec.tsx b/frontend/src/components/MeetingRoom/ToolbarOverflowMenu/ToolbarOverflowMenu.spec.tsx
index 4b339162..c9a225c0 100644
--- a/frontend/src/components/MeetingRoom/ToolbarOverflowMenu/ToolbarOverflowMenu.spec.tsx
+++ b/frontend/src/components/MeetingRoom/ToolbarOverflowMenu/ToolbarOverflowMenu.spec.tsx
@@ -1,19 +1,20 @@
import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest';
-import { render, screen } from '@testing-library/react';
-import { useRef } from 'react';
+import { render as renderBase, screen } from '@testing-library/react';
+import { ReactElement, useRef } from 'react';
import { Button } from '@mui/material';
+import * as util from '@utils/util';
+import isReportIssueEnabled from '@utils/isReportIssueEnabled';
+import { makeAppConfigProviderWrapper } from '@test/providers';
import ToolbarOverflowMenu, { CaptionsState } from './ToolbarOverflowMenu';
-import * as util from '../../../utils/util';
-import isReportIssueEnabled from '../../../utils/isReportIssueEnabled';
-vi.mock('../../../hooks/useSessionContext', () => ({
+vi.mock('@hooks/useSessionContext', () => ({
default: () => ({
subscriberWrappers: [],
}),
}));
-vi.mock('../../../hooks/useRoomName');
-vi.mock('../../../utils/util', () => ({ isMobile: vi.fn() }));
-vi.mock('../../../utils/isReportIssueEnabled');
+vi.mock('@hooks/useRoomName');
+vi.mock('@utils/util', () => ({ isMobile: vi.fn() }));
+vi.mock('@utils/isReportIssueEnabled');
const mockOpenEmojiGrid = vi.fn();
const mockHandleClickAway = vi.fn();
@@ -109,3 +110,9 @@ describe('ToolbarOverflowMenu', () => {
});
});
});
+
+function render(ui: ReactElement) {
+ const { AppConfigWrapper } = makeAppConfigProviderWrapper();
+
+ return renderBase(ui, { wrapper: AppConfigWrapper });
+}
diff --git a/frontend/src/components/MeetingRoom/UnreadMessagesBadge/UnreadMessagesBadge.spec.tsx b/frontend/src/components/MeetingRoom/UnreadMessagesBadge/UnreadMessagesBadge.spec.tsx
index 1aeac66b..6eef5b40 100644
--- a/frontend/src/components/MeetingRoom/UnreadMessagesBadge/UnreadMessagesBadge.spec.tsx
+++ b/frontend/src/components/MeetingRoom/UnreadMessagesBadge/UnreadMessagesBadge.spec.tsx
@@ -1,31 +1,24 @@
import { describe, it, vi, expect, Mock, beforeEach } from 'vitest';
-import { render, screen } from '@testing-library/react';
+import { render as renderBase, screen } from '@testing-library/react';
import BiotechIcon from '@mui/icons-material/Biotech';
-import useSessionContext from '../../../hooks/useSessionContext';
-import { SessionContextType } from '../../../Context/SessionProvider/session';
+import { ReactElement } from 'react';
+import useSessionContext from '@hooks/useSessionContext';
+import { SessionContextType } from '@Context/SessionProvider/session';
+import { AppConfigProviderWrapperOptions, makeAppConfigProviderWrapper } from '@test/providers';
import UnreadMessagesBadge from './UnreadMessagesBadge';
import ToolbarButton from '../ToolbarButton';
-import useConfigContext from '../../../hooks/useConfigContext';
-import { ConfigContextType } from '../../../Context/ConfigProvider';
-vi.mock('../../../hooks/useSessionContext');
-vi.mock('../../../hooks/useConfigContext');
+vi.mock('@hooks/useSessionContext');
+
const mockUseSessionContext = useSessionContext as Mock<[], SessionContextType>;
const sessionContext = {
unreadCount: 0,
} as unknown as SessionContextType;
const LittleButton = () => {}} icon={} />;
-const mockConfigContext = {
- meetingRoomSettings: {
- allowChat: true,
- },
-} as Partial as ConfigContextType;
-const mockUseConfigContext = useConfigContext as Mock<[], ConfigContextType>;
describe('UnreadMessagesBadge', () => {
beforeEach(() => {
mockUseSessionContext.mockReturnValue(sessionContext);
- mockUseConfigContext.mockReturnValue(mockConfigContext);
});
it('shows badge with correct unread message count', () => {
@@ -177,16 +170,20 @@ describe('UnreadMessagesBadge', () => {
unreadCount: 8,
} as unknown as SessionContextType;
mockUseSessionContext.mockReturnValue(sessionContextWithMessages);
- mockUseConfigContext.mockReturnValue({
- meetingRoomSettings: {
- allowChat: false,
- },
- } as Partial as ConfigContextType);
render(
-
+ ,
+ {
+ appConfigOptions: {
+ value: {
+ meetingRoomSettings: {
+ allowChat: false,
+ },
+ },
+ },
+ }
);
const badge = screen.getByTestId('chat-button-unread-count');
@@ -195,3 +192,14 @@ describe('UnreadMessagesBadge', () => {
expect(badge.offsetWidth).toBe(0);
});
});
+
+function render(
+ ui: ReactElement,
+ options?: {
+ appConfigOptions?: AppConfigProviderWrapperOptions;
+ }
+) {
+ const { AppConfigWrapper } = makeAppConfigProviderWrapper(options?.appConfigOptions);
+
+ return renderBase(ui, { ...options, wrapper: AppConfigWrapper });
+}
diff --git a/frontend/src/components/MeetingRoom/UnreadMessagesBadge/UnreadMessagesBadge.tsx b/frontend/src/components/MeetingRoom/UnreadMessagesBadge/UnreadMessagesBadge.tsx
index bbb23f80..37f264c8 100644
--- a/frontend/src/components/MeetingRoom/UnreadMessagesBadge/UnreadMessagesBadge.tsx
+++ b/frontend/src/components/MeetingRoom/UnreadMessagesBadge/UnreadMessagesBadge.tsx
@@ -1,7 +1,7 @@
import { Badge } from '@mui/material';
import { ForwardedRef, forwardRef, ReactElement } from 'react';
-import useSessionContext from '../../../hooks/useSessionContext';
-import useConfigContext from '../../../hooks/useConfigContext';
+import useSessionContext from '@hooks/useSessionContext';
+import useIsMeetingChatAllowed from '@Context/AppConfig/hooks/useIsMeetingChatAllowed';
export type UnreadMessagesBadgeProps = {
children: ReactElement;
@@ -21,12 +21,12 @@ const UnreadMessagesBadge = forwardRef(function UnreadMessagesBadge(
props: UnreadMessagesBadgeProps,
ref: ForwardedRef
) {
- const { meetingRoomSettings } = useConfigContext();
+ const isMeetingChatAllowed = useIsMeetingChatAllowed();
+
const { children, isToolbarOverflowMenuOpen, ...rest } = props;
const { unreadCount } = useSessionContext();
- const { allowChat } = meetingRoomSettings;
// If the chat button is not shown, the unread messages badge should also be hidden
- const isInvisible = unreadCount === 0 || isToolbarOverflowMenuOpen || !allowChat;
+ const isInvisible = unreadCount === 0 || isToolbarOverflowMenuOpen || !isMeetingChatAllowed;
return (
({
+vi.mock('@hooks/useDevices');
+vi.mock('@hooks/usePublisherContext');
+vi.mock('@utils/storage', () => ({
setStorageItem: vi.fn(),
STORAGE_KEYS: {
VIDEO_SOURCE: 'videoSource',
},
}));
-vi.mock('../../../hooks/useConfigContext');
const mockUseDevices = useDevices as Mock<
[],
{ allMediaDevices: AllMediaDevices; getAllMediaDevices: () => void }
>;
const mockUsePublisherContext = usePublisherContext as Mock<[], PublisherContextType>;
-const mockUseConfigContext = useConfigContext as Mock<[], ConfigContextType>;
describe('VideoDevices Component', () => {
const mockHandleToggle = vi.fn();
@@ -38,7 +36,6 @@ describe('VideoDevices Component', () => {
}));
let mockPublisher: Publisher;
let publisherContext: PublisherContextType;
- let mockConfigContext: ConfigContextType;
beforeEach(() => {
mockUseDevices.mockReturnValue({
@@ -63,18 +60,12 @@ describe('VideoDevices Component', () => {
publisherContext.publisher = mockPublisher;
}) as unknown as () => void,
} as unknown as PublisherContextType;
- mockConfigContext = {
- meetingRoomSettings: {
- allowDeviceSelection: true,
- },
- } as Partial as ConfigContextType;
mockUsePublisherContext.mockImplementation(() => publisherContext);
mockGetVideoSource.mockReturnValue({
deviceId: 'a68ec4e4a6bc10dc572bd806414b0da27d0aefb0ad822f7ba4cf9b226bb9b7c2',
label: 'FaceTime HD Camera (2C0E:82E3)',
});
- mockUseConfigContext.mockReturnValue(mockConfigContext);
});
afterEach(() => {
@@ -110,11 +101,27 @@ describe('VideoDevices Component', () => {
});
it('is not rendered when allowDeviceSelection is false', () => {
- mockConfigContext.meetingRoomSettings.allowDeviceSelection = false;
- mockUseConfigContext.mockReturnValue(mockConfigContext);
-
- const { container } = render();
+ const { container } = render(, {
+ appConfigOptions: {
+ value: {
+ meetingRoomSettings: {
+ allowDeviceSelection: false,
+ },
+ },
+ },
+ });
expect(container.firstChild).toBeNull();
});
});
+
+function render(
+ ui: ReactElement,
+ options?: {
+ appConfigOptions?: AppConfigProviderWrapperOptions;
+ }
+) {
+ const { AppConfigWrapper } = makeAppConfigProviderWrapper(options?.appConfigOptions);
+
+ return renderBase(ui, { ...options, wrapper: AppConfigWrapper });
+}
diff --git a/frontend/src/components/MeetingRoom/VideoDevices/VideoDevices.tsx b/frontend/src/components/MeetingRoom/VideoDevices/VideoDevices.tsx
index 283f7a17..6651103e 100644
--- a/frontend/src/components/MeetingRoom/VideoDevices/VideoDevices.tsx
+++ b/frontend/src/components/MeetingRoom/VideoDevices/VideoDevices.tsx
@@ -4,10 +4,10 @@ import CheckIcon from '@mui/icons-material/Check';
import VideocamIcon from '@mui/icons-material/Videocam';
import { Device } from '@vonage/client-sdk-video';
import { useTranslation } from 'react-i18next';
+import useAppConfig from '@Context/AppConfig/hooks/useAppConfig';
import useDevices from '../../../hooks/useDevices';
import usePublisherContext from '../../../hooks/usePublisherContext';
import { setStorageItem, STORAGE_KEYS } from '../../../utils/storage';
-import useConfigContext from '../../../hooks/useConfigContext';
import cleanAndDedupeDeviceLabels from '../../../utils/cleanAndDedupeDeviceLabels';
import { colors } from '../../../utils/customTheme/customTheme';
@@ -26,11 +26,14 @@ export type VideoDevicesProps = {
const VideoDevices = ({ handleToggle }: VideoDevicesProps): ReactElement | false => {
const { t } = useTranslation();
const { isPublishing, publisher } = usePublisherContext();
- const { meetingRoomSettings } = useConfigContext();
+
+ const allowDeviceSelection = useAppConfig(
+ ({ meetingRoomSettings }) => meetingRoomSettings.allowDeviceSelection
+ );
+
const { allMediaDevices } = useDevices();
const [devicesAvailable, setDevicesAvailable] = useState([]);
const [options, setOptions] = useState<{ deviceId: string; label: string }[]>([]);
- const { allowDeviceSelection } = meetingRoomSettings;
const changeVideoSource = (videoDeviceId: string) => {
publisher?.setVideoSource(videoDeviceId);
diff --git a/frontend/src/components/ScreenSharingButton/ScreenSharingButton.spec.tsx b/frontend/src/components/ScreenSharingButton/ScreenSharingButton.spec.tsx
index c036644a..a37f3dc9 100644
--- a/frontend/src/components/ScreenSharingButton/ScreenSharingButton.spec.tsx
+++ b/frontend/src/components/ScreenSharingButton/ScreenSharingButton.spec.tsx
@@ -1,23 +1,10 @@
-import { beforeEach, describe, expect, it, Mock, vi } from 'vitest';
-import { fireEvent, render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import { fireEvent, render as renderBase, screen } from '@testing-library/react';
+import { ReactElement } from 'react';
+import { AppConfigProviderWrapperOptions, makeAppConfigProviderWrapper } from '@test/providers';
import ScreenSharingButton, { ScreenShareButtonProps } from './ScreenSharingButton';
-import useConfigContext from '../../hooks/useConfigContext';
-import { ConfigContextType } from '../../Context/ConfigProvider';
-
-vi.mock('../../hooks/useConfigContext');
-
-const mockUseConfigContext = useConfigContext as Mock<[], ConfigContextType>;
-const mockConfigContext = {
- meetingRoomSettings: {
- allowScreenShare: true,
- },
-} as Partial as ConfigContextType;
describe('ScreenSharingButton', () => {
- beforeEach(() => {
- mockUseConfigContext.mockReturnValue(mockConfigContext);
- });
-
const mockToggleScreenShare = vi.fn();
const defaultProps: ScreenShareButtonProps = {
@@ -58,8 +45,27 @@ describe('ScreenSharingButton', () => {
});
it('is not rendered when allowScreenShare is false', () => {
- mockConfigContext.meetingRoomSettings.allowScreenShare = false;
- render();
+ render(, {
+ appConfigOptions: {
+ value: {
+ meetingRoomSettings: {
+ allowScreenShare: false,
+ },
+ },
+ },
+ });
+
expect(screen.queryByTestId('ScreenShareIcon')).not.toBeInTheDocument();
});
});
+
+function render(
+ ui: ReactElement,
+ options?: {
+ appConfigOptions?: AppConfigProviderWrapperOptions;
+ }
+) {
+ const { AppConfigWrapper } = makeAppConfigProviderWrapper(options?.appConfigOptions);
+
+ return renderBase(ui, { ...options, wrapper: AppConfigWrapper });
+}
diff --git a/frontend/src/components/ScreenSharingButton/ScreenSharingButton.tsx b/frontend/src/components/ScreenSharingButton/ScreenSharingButton.tsx
index 6dbd1f0b..d195c1b3 100644
--- a/frontend/src/components/ScreenSharingButton/ScreenSharingButton.tsx
+++ b/frontend/src/components/ScreenSharingButton/ScreenSharingButton.tsx
@@ -3,10 +3,10 @@ import ScreenShare from '@mui/icons-material/ScreenShare';
import Tooltip from '@mui/material/Tooltip';
import { ReactElement, useState } from 'react';
import { useTranslation } from 'react-i18next';
+import useAppConfig from '@Context/AppConfig/hooks/useAppConfig';
+import { isMobile } from '@utils/util';
import ToolbarButton from '../MeetingRoom/ToolbarButton';
import PopupDialog, { DialogTexts } from '../MeetingRoom/PopupDialog';
-import { isMobile } from '../../utils/util';
-import useConfigContext from '../../hooks/useConfigContext';
export type ScreenShareButtonProps = {
toggleScreenShare: () => void;
@@ -32,11 +32,12 @@ const ScreenSharingButton = ({
isViewingScreenShare,
isOverflowButton = false,
}: ScreenShareButtonProps): ReactElement | false => {
- const { meetingRoomSettings } = useConfigContext();
+ const allowScreenShare = useAppConfig(
+ ({ meetingRoomSettings }) => meetingRoomSettings.allowScreenShare
+ );
const { t } = useTranslation();
const title = isSharingScreen ? t('screenSharing.title.stop') : t('screenSharing.title.start');
const [isModalOpen, setIsModalOpen] = useState(false);
- const { allowScreenShare } = meetingRoomSettings;
// Screensharing relies on the getDisplayMedia browser API which is unsupported on mobile devices
// See: https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getDisplayMedia#browser_compatibility
diff --git a/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsButton/BackgroundEffectsButton.spec.tsx b/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsButton/BackgroundEffectsButton.spec.tsx
index 3daca2dd..8c967a27 100644
--- a/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsButton/BackgroundEffectsButton.spec.tsx
+++ b/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsButton/BackgroundEffectsButton.spec.tsx
@@ -1,9 +1,9 @@
-import { render, screen } from '@testing-library/react';
+import { render as renderBase, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
-import { beforeEach, describe, expect, it, Mock, vi } from 'vitest';
+import { describe, expect, it, vi } from 'vitest';
+import { ReactElement } from 'react';
+import { AppConfigProviderWrapperOptions, makeAppConfigProviderWrapper } from '@test/providers';
import BackgroundEffectsButton from './BackgroundEffectsButton';
-import useConfigContext from '../../../../hooks/useConfigContext';
-import { ConfigContextType } from '../../../../Context/ConfigProvider';
const { mockHasMediaProcessorSupport } = vi.hoisted(() => {
return {
@@ -14,22 +14,8 @@ vi.mock('@vonage/client-sdk-video', () => ({
hasMediaProcessorSupport: mockHasMediaProcessorSupport,
}));
-vi.mock('../../../../hooks/useConfigContext');
-const mockUseConfigContext = useConfigContext as Mock<[], ConfigContextType>;
-
describe('BackgroundEffectsButton', () => {
const mockOnClick = vi.fn();
- let mockConfigContext: ConfigContextType;
-
- beforeEach(() => {
- vi.clearAllMocks();
- mockConfigContext = {
- videoSettings: {
- allowBackgroundEffects: true,
- },
- } as Partial as ConfigContextType;
- mockUseConfigContext.mockReturnValue(mockConfigContext);
- });
it('renders the button if media processor is supported', () => {
mockHasMediaProcessorSupport.mockReturnValue(true);
@@ -52,8 +38,26 @@ describe('BackgroundEffectsButton', () => {
});
it('is not rendered when background effects are not allowed', () => {
- mockConfigContext.videoSettings.allowBackgroundEffects = false;
- render();
+ render(, {
+ appConfigOptions: {
+ value: {
+ videoSettings: {
+ allowBackgroundEffects: false,
+ },
+ },
+ },
+ });
expect(screen.queryByLabelText(/background effects/i)).not.toBeInTheDocument();
});
});
+
+function render(
+ ui: ReactElement,
+ options?: {
+ appConfigOptions?: AppConfigProviderWrapperOptions;
+ }
+) {
+ const { AppConfigWrapper } = makeAppConfigProviderWrapper(options?.appConfigOptions);
+
+ return renderBase(ui, { ...options, wrapper: AppConfigWrapper });
+}
diff --git a/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsButton/BackgroundEffectsButton.tsx b/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsButton/BackgroundEffectsButton.tsx
index 821542b9..e4d4b1e2 100644
--- a/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsButton/BackgroundEffectsButton.tsx
+++ b/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsButton/BackgroundEffectsButton.tsx
@@ -3,8 +3,8 @@ import { hasMediaProcessorSupport } from '@vonage/client-sdk-video';
import { ReactElement } from 'react';
import PortraitIcon from '@mui/icons-material/Portrait';
import { useTranslation } from 'react-i18next';
+import useAppConfig from '@Context/AppConfig/hooks/useAppConfig';
import VideoContainerButton from '../../VideoContainerButton';
-import useConfigContext from '../../../../hooks/useConfigContext';
export type BackgroundEffectsButtonProps = {
onClick: () => void;
@@ -21,8 +21,10 @@ export type BackgroundEffectsButtonProps = {
const BackgroundEffectsButton = ({
onClick,
}: BackgroundEffectsButtonProps): ReactElement | false => {
- const config = useConfigContext();
- const { allowBackgroundEffects } = config.videoSettings;
+ const allowBackgroundEffects = useAppConfig(
+ ({ videoSettings }) => videoSettings.allowBackgroundEffects
+ );
+
const shouldDisplayBackgroundEffects = hasMediaProcessorSupport() && allowBackgroundEffects;
const { t } = useTranslation();
diff --git a/frontend/src/components/WaitingRoom/CameraButton/CameraButton.spec.tsx b/frontend/src/components/WaitingRoom/CameraButton/CameraButton.spec.tsx
index d75dc9b4..fe45d8d4 100644
--- a/frontend/src/components/WaitingRoom/CameraButton/CameraButton.spec.tsx
+++ b/frontend/src/components/WaitingRoom/CameraButton/CameraButton.spec.tsx
@@ -1,8 +1,8 @@
-import { render, screen, fireEvent } from '@testing-library/react';
-import { describe, it, expect, vi, beforeEach, Mock } from 'vitest';
+import { render as renderBase, screen, fireEvent } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { ReactElement } from 'react';
+import { AppConfigProviderWrapperOptions, makeAppConfigProviderWrapper } from '@test/providers';
import CameraButton from './CameraButton';
-import useConfigContext from '../../../hooks/useConfigContext';
-import { ConfigContextType } from '../../../Context/ConfigProvider';
let isVideoEnabled = true;
const toggleVideoMock = vi.fn();
@@ -26,21 +26,10 @@ vi.mock('../../../hooks/useBackgroundPublisherContext', () => {
};
});
-vi.mock('../../../hooks/useConfigContext');
-const mockUseConfigContext = useConfigContext as Mock<[], ConfigContextType>;
-
describe('CameraButton', () => {
- let mockConfigContext: ConfigContextType;
-
beforeEach(() => {
vi.clearAllMocks();
isVideoEnabled = true;
- mockConfigContext = {
- videoSettings: {
- allowCameraControl: true,
- },
- } as Partial as ConfigContextType;
- mockUseConfigContext.mockReturnValue(mockConfigContext);
});
it('renders the video on icon when video is enabled', () => {
@@ -62,8 +51,28 @@ describe('CameraButton', () => {
});
it('is not rendered when allowCameraControl is false', () => {
- mockConfigContext.videoSettings.allowCameraControl = false;
- render();
+ render(, {
+ appConfigOptions: {
+ value: {
+ isAppConfigLoaded: true,
+ videoSettings: {
+ allowCameraControl: false,
+ },
+ },
+ },
+ });
+
expect(screen.queryByTestId('VideocamIcon')).not.toBeInTheDocument();
});
});
+
+function render(
+ ui: ReactElement,
+ options?: {
+ appConfigOptions?: AppConfigProviderWrapperOptions;
+ }
+) {
+ const { AppConfigWrapper } = makeAppConfigProviderWrapper(options?.appConfigOptions);
+
+ return renderBase(ui, { ...options, wrapper: AppConfigWrapper });
+}
diff --git a/frontend/src/components/WaitingRoom/CameraButton/CameraButton.tsx b/frontend/src/components/WaitingRoom/CameraButton/CameraButton.tsx
index fe0f41ee..4e6c758c 100644
--- a/frontend/src/components/WaitingRoom/CameraButton/CameraButton.tsx
+++ b/frontend/src/components/WaitingRoom/CameraButton/CameraButton.tsx
@@ -3,10 +3,10 @@ import VideocamIcon from '@mui/icons-material/Videocam';
import VideocamOffIcon from '@mui/icons-material/VideocamOff';
import { ReactElement } from 'react';
import { useTranslation } from 'react-i18next';
-import usePreviewPublisherContext from '../../../hooks/usePreviewPublisherContext';
+import usePreviewPublisherContext from '@hooks/usePreviewPublisherContext';
+import useBackgroundPublisherContext from '@hooks/useBackgroundPublisherContext';
+import useIsCameraControlAllowed from '@Context/AppConfig/hooks/useIsCameraControlAllowed';
import VideoContainerButton from '../VideoContainerButton';
-import useBackgroundPublisherContext from '../../../hooks/useBackgroundPublisherContext';
-import useConfigContext from '../../../hooks/useConfigContext';
/**
* CameraButton Component
@@ -18,11 +18,11 @@ const CameraButton = (): ReactElement | false => {
const { t } = useTranslation();
const { isVideoEnabled, toggleVideo } = usePreviewPublisherContext();
const { toggleVideo: toggleBackgroundVideoPublisher } = useBackgroundPublisherContext();
- const { videoSettings } = useConfigContext();
+ const allowCameraControl = useIsCameraControlAllowed();
+
const title = isVideoEnabled
? t('devices.video.camera.state.off')
: t('devices.video.camera.state.on');
- const { allowCameraControl } = videoSettings;
const handleToggleVideo = () => {
toggleVideo();
diff --git a/frontend/src/components/WaitingRoom/ControlPanel/ControlPanel.spec.tsx b/frontend/src/components/WaitingRoom/ControlPanel/ControlPanel.spec.tsx
index d3df5f6e..cbcc2ad6 100644
--- a/frontend/src/components/WaitingRoom/ControlPanel/ControlPanel.spec.tsx
+++ b/frontend/src/components/WaitingRoom/ControlPanel/ControlPanel.spec.tsx
@@ -1,19 +1,18 @@
import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest';
-import { cleanup, screen, render } from '@testing-library/react';
+import { cleanup, screen, render as renderBase } from '@testing-library/react';
+import { ReactElement } from 'react';
+import useDevices from '@hooks/useDevices';
+import { AllMediaDevices } from '@app-types/room';
+import { allMediaDevices } from '@utils/mockData/device';
+import { AppConfigProviderWrapperOptions, makeAppConfigProviderWrapper } from '@test/providers';
import ControlPanel from '.';
-import useDevices from '../../../hooks/useDevices';
-import { AllMediaDevices } from '../../../types';
-import { allMediaDevices } from '../../../utils/mockData/device';
-import useConfigContext from '../../../hooks/useConfigContext';
-import { ConfigContextType } from '../../../Context/ConfigProvider';
-
-vi.mock('../../../hooks/useDevices.tsx');
-vi.mock('../../../hooks/useConfigContext');
+
+vi.mock('@hooks/useDevices.tsx');
+
const mockUseDevices = useDevices as Mock<
[],
{ allMediaDevices: AllMediaDevices; getAllMediaDevices: () => void }
>;
-const mockUseConfigContext = useConfigContext as Mock<[], ConfigContextType>;
describe('ControlPanel', () => {
beforeEach(() => {
@@ -21,11 +20,6 @@ describe('ControlPanel', () => {
getAllMediaDevices: vi.fn(),
allMediaDevices,
});
- mockUseConfigContext.mockReturnValue({
- waitingRoomSettings: {
- allowDeviceSelection: true,
- },
- } as Partial as ConfigContextType);
});
afterEach(() => {
@@ -141,12 +135,6 @@ describe('ControlPanel', () => {
});
it('is not rendered when allowDeviceSelection is false', () => {
- mockUseConfigContext.mockReturnValue({
- waitingRoomSettings: {
- allowDeviceSelection: false,
- },
- } as Partial as ConfigContextType);
-
render(
{}}
@@ -157,9 +145,29 @@ describe('ControlPanel', () => {
openVideoInput={false}
openAudioOutput={false}
anchorEl={null}
- />
+ />,
+ {
+ appConfigOptions: {
+ value: {
+ waitingRoomSettings: {
+ allowDeviceSelection: false,
+ },
+ },
+ },
+ }
);
expect(screen.queryByTestId('ControlPanel')).not.toBeInTheDocument();
});
});
+
+function render(
+ ui: ReactElement,
+ options?: {
+ appConfigOptions?: AppConfigProviderWrapperOptions;
+ }
+) {
+ const { AppConfigWrapper } = makeAppConfigProviderWrapper(options?.appConfigOptions);
+
+ return renderBase(ui, { ...options, wrapper: AppConfigWrapper });
+}
diff --git a/frontend/src/components/WaitingRoom/ControlPanel/ControlPanel.tsx b/frontend/src/components/WaitingRoom/ControlPanel/ControlPanel.tsx
index 4b9e9ccd..52453652 100644
--- a/frontend/src/components/WaitingRoom/ControlPanel/ControlPanel.tsx
+++ b/frontend/src/components/WaitingRoom/ControlPanel/ControlPanel.tsx
@@ -5,12 +5,12 @@ import VideoCall from '@mui/icons-material/VideoCall';
import Speaker from '@mui/icons-material/Speaker';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import { useTranslation } from 'react-i18next';
+import usePreviewPublisherContext from '@hooks/usePreviewPublisherContext';
+import useDevices from '@hooks/useDevices';
+import useAudioOutputContext from '@hooks/useAudioOutputContext';
+import useIsSmallViewport from '@hooks/useIsSmallViewport';
+import useAppConfig from '@Context/AppConfig/hooks/useAppConfig';
import MenuDevicesWaitingRoom from '../MenuDevices';
-import usePreviewPublisherContext from '../../../hooks/usePreviewPublisherContext';
-import useDevices from '../../../hooks/useDevices';
-import useAudioOutputContext from '../../../hooks/useAudioOutputContext';
-import useIsSmallViewport from '../../../hooks/useIsSmallViewport';
-import useConfigContext from '../../../hooks/useConfigContext';
export type ControlPanelProps = {
handleAudioInputOpen: (
@@ -60,8 +60,10 @@ const ControlPanel = ({
const { localAudioSource, localVideoSource, changeAudioSource, changeVideoSource } =
usePreviewPublisherContext();
const { currentAudioOutputDevice, setAudioOutputDevice } = useAudioOutputContext();
- const { waitingRoomSettings } = useConfigContext();
- const { allowDeviceSelection } = waitingRoomSettings;
+
+ const allowDeviceSelection = useAppConfig(
+ ({ waitingRoomSettings }) => waitingRoomSettings.allowDeviceSelection
+ );
const buttonSx: SxProps = {
borderRadius: '10px',
diff --git a/frontend/src/components/WaitingRoom/MicButton/MicButton.spec.tsx b/frontend/src/components/WaitingRoom/MicButton/MicButton.spec.tsx
index 8d26d02d..b5ebc4c0 100644
--- a/frontend/src/components/WaitingRoom/MicButton/MicButton.spec.tsx
+++ b/frontend/src/components/WaitingRoom/MicButton/MicButton.spec.tsx
@@ -1,8 +1,8 @@
-import { render, screen, fireEvent } from '@testing-library/react';
-import { describe, it, expect, vi, beforeEach, Mock } from 'vitest';
+import { render as renderBase, screen, fireEvent } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { ReactElement } from 'react';
+import { AppConfigProviderWrapperOptions, makeAppConfigProviderWrapper } from '@test/providers';
import MicButton from './MicButton';
-import useConfigContext from '../../../hooks/useConfigContext';
-import { ConfigContextType } from '../../../Context/ConfigProvider';
let isAudioEnabled = true;
const toggleAudioMock = vi.fn();
@@ -18,21 +18,9 @@ vi.mock('../../../hooks/usePreviewPublisherContext', () => {
};
});
-vi.mock('../../../hooks/useConfigContext');
-const mockUseConfigContext = useConfigContext as Mock<[], ConfigContextType>;
-
describe('MicButton', () => {
- let mockConfigContext: ConfigContextType;
-
beforeEach(() => {
- vi.clearAllMocks();
isAudioEnabled = true;
- mockConfigContext = {
- audioSettings: {
- allowMicrophoneControl: true,
- },
- } as Partial as ConfigContextType;
- mockUseConfigContext.mockReturnValue(mockConfigContext);
});
it('renders the mic on icon when audio is enabled', () => {
@@ -53,8 +41,27 @@ describe('MicButton', () => {
});
it('is not rendered when allowMicrophoneControl is false', () => {
- mockConfigContext.audioSettings.allowMicrophoneControl = false;
- render();
+ render(, {
+ appConfigOptions: {
+ value: {
+ audioSettings: {
+ allowMicrophoneControl: false,
+ },
+ },
+ },
+ });
+
expect(screen.queryByTestId('MicIcon')).not.toBeInTheDocument();
});
});
+
+function render(
+ ui: ReactElement,
+ options?: {
+ appConfigOptions?: AppConfigProviderWrapperOptions;
+ }
+) {
+ const { AppConfigWrapper } = makeAppConfigProviderWrapper(options?.appConfigOptions);
+
+ return renderBase(ui, { ...options, wrapper: AppConfigWrapper });
+}
diff --git a/frontend/src/components/WaitingRoom/MicButton/MicButton.tsx b/frontend/src/components/WaitingRoom/MicButton/MicButton.tsx
index 9bd235d3..77ce557b 100644
--- a/frontend/src/components/WaitingRoom/MicButton/MicButton.tsx
+++ b/frontend/src/components/WaitingRoom/MicButton/MicButton.tsx
@@ -3,9 +3,9 @@ import { MicOff } from '@mui/icons-material';
import MicIcon from '@mui/icons-material/Mic';
import { ReactElement } from 'react';
import { useTranslation } from 'react-i18next';
-import usePreviewPublisherContext from '../../../hooks/usePreviewPublisherContext';
+import useIsMicrophoneControlAllowed from '@Context/AppConfig/hooks/useIsMicrophoneControlAllowed';
+import usePreviewPublisherContext from '@hooks/usePreviewPublisherContext';
import VideoContainerButton from '../VideoContainerButton';
-import useConfigContext from '../../../hooks/useConfigContext';
/**
* MicButton Component
@@ -16,11 +16,12 @@ import useConfigContext from '../../../hooks/useConfigContext';
const MicButton = (): ReactElement | false => {
const { t } = useTranslation();
const { isAudioEnabled, toggleAudio } = usePreviewPublisherContext();
- const config = useConfigContext();
+
+ const allowMicrophoneControl = useIsMicrophoneControlAllowed();
+
const title = isAudioEnabled
? t('devices.audio.microphone.state.off')
: t('devices.audio.microphone.state.on');
- const { allowMicrophoneControl } = config.audioSettings;
return (
allowMicrophoneControl && (
diff --git a/frontend/src/components/WaitingRoom/VideoLoading/VideoLoading.spec.tsx b/frontend/src/components/WaitingRoom/VideoLoading/VideoLoading.spec.tsx
index ee11bc06..3a83e346 100644
--- a/frontend/src/components/WaitingRoom/VideoLoading/VideoLoading.spec.tsx
+++ b/frontend/src/components/WaitingRoom/VideoLoading/VideoLoading.spec.tsx
@@ -12,10 +12,4 @@ describe('VideoLoading', () => {
expect(screen.getByTestId('VideoLoading')).toBeInTheDocument();
});
-
- it('should contain a CircularProgress component', () => {
- render();
-
- expect(screen.getByTestId('CircularProgress')).toBeInTheDocument();
- });
});
diff --git a/frontend/src/hooks/useConfigContext.ts b/frontend/src/hooks/useConfigContext.ts
deleted file mode 100644
index 94648ed3..00000000
--- a/frontend/src/hooks/useConfigContext.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import { useContext } from 'react';
-import { ConfigContext, ConfigContextType } from '../Context/ConfigProvider';
-
-/**
- * Custom hook to access the Config context containing comprehensive application configuration settings.
- * Provides access to video settings (background effects, camera control, resolution), audio settings
- * (noise suppression, microphone control), waiting room settings (device selection), and meeting room
- * settings (layout mode, UI button visibility). Configuration is loaded from config.json and merged
- * with default values via the useConfig hook.
- * @returns {ConfigContextType} The config context value with all application settings
- */
-const useConfigContext = (): ConfigContextType => {
- const context = useContext(ConfigContext);
-
- return context;
-};
-
-export default useConfigContext;
diff --git a/frontend/src/pages/MeetingRoom/MeetingRoom.spec.tsx b/frontend/src/pages/MeetingRoom/MeetingRoom.spec.tsx
index d788e32a..1e5c9478 100644
--- a/frontend/src/pages/MeetingRoom/MeetingRoom.spec.tsx
+++ b/frontend/src/pages/MeetingRoom/MeetingRoom.spec.tsx
@@ -1,35 +1,38 @@
import '../../css/index.css';
import { beforeEach, describe, expect, it, Mock, vi } from 'vitest';
-import { render, screen } from '@testing-library/react';
+import { render as renderBase, screen } from '@testing-library/react';
import { Publisher, Subscriber } from '@vonage/client-sdk-video';
import { EventEmitter } from 'stream';
import * as mui from '@mui/material';
-import MeetingRoom from './MeetingRoom';
-import UserProvider, { UserContextType } from '../../Context/user';
-import SessionProvider, { SessionContextType } from '../../Context/SessionProvider/session';
-import { SubscriberWrapper } from '../../types/session';
-import { PublisherContextType, PublisherProvider } from '../../Context/PublisherProvider';
-import usePublisherContext from '../../hooks/usePublisherContext';
-import useUserContext from '../../hooks/useUserContext';
-import useDevices from '../../hooks/useDevices';
-import { AllMediaDevices } from '../../types';
-import { allMediaDevices, defaultAudioDevice } from '../../utils/mockData/device';
-import useSpeakingDetector from '../../hooks/useSpeakingDetector';
-import useLayoutManager, { GetLayout } from '../../hooks/useLayoutManager';
-import useSessionContext from '../../hooks/useSessionContext';
-import useActiveSpeaker from '../../hooks/useActiveSpeaker';
-import useScreenShare, { UseScreenShareType } from '../../hooks/useScreenShare';
-import { RIGHT_PANEL_BUTTON_COUNT } from '../../utils/constants';
+import { ReactElement } from 'react';
+import UserProvider, { UserContextType } from '@Context/user';
+import SessionProvider, { SessionContextType } from '@Context/SessionProvider/session';
+import { SubscriberWrapper } from '@app-types/session';
+import { PublisherContextType, PublisherProvider } from '@Context/PublisherProvider';
+import usePublisherContext from '@hooks/usePublisherContext';
+import useUserContext from '@hooks/useUserContext';
+import useDevices from '@hooks/useDevices';
+import { AllMediaDevices } from '@app-types/room';
+import { allMediaDevices, defaultAudioDevice } from '@utils/mockData/device';
+import useSpeakingDetector from '@hooks/useSpeakingDetector';
+import useLayoutManager, { GetLayout } from '@hooks/useLayoutManager';
+import useSessionContext from '@hooks/useSessionContext';
+import useActiveSpeaker from '@hooks/useActiveSpeaker';
+import useScreenShare, { UseScreenShareType } from '@hooks/useScreenShare';
+import { RIGHT_PANEL_BUTTON_COUNT } from '@utils/constants';
import useToolbarButtons, {
UseToolbarButtons,
UseToolbarButtonsProps,
-} from '../../hooks/useToolbarButtons';
-import usePublisherOptions from '../../Context/PublisherProvider/usePublisherOptions';
+} from '@hooks/useToolbarButtons';
+import usePublisherOptions from '@Context/PublisherProvider/usePublisherOptions';
+import { makeAppConfigProviderWrapper } from '@test/providers';
+import composeProviders from '@utils/composeProviders';
+import MeetingRoom from './MeetingRoom';
const mockedNavigate = vi.fn();
const mockedParams = { roomName: 'test-room-name' };
const mockedLocation = vi.fn();
-vi.mock('../../hooks/useBackgroundPublisherContext', () => ({
+vi.mock('@hooks/useBackgroundPublisherContext', () => ({
__esModule: true,
default: () => ({
initBackgroundLocalPublisher: vi.fn(),
@@ -54,38 +57,17 @@ vi.mock('@mui/material', async () => {
useMediaQuery: vi.fn(),
};
});
-vi.mock('../../hooks/useConfigContext', () => {
- return {
- default: () => ({
- videoSettings: {
- allowCameraControl: true,
- },
- audioSettings: {
- allowMicrophoneControl: true,
- },
- meetingRoomSettings: {
- defaultLayoutMode: 'active-speaker',
- showParticipantList: true,
- allowChat: true,
- allowScreenShare: true,
- allowArchiving: true,
- allowCaptions: true,
- allowEmojis: true,
- },
- }),
- };
-});
-vi.mock('../../hooks/useDevices.tsx');
-vi.mock('../../hooks/usePublisherContext.tsx');
-vi.mock('../../hooks/useUserContext.tsx');
-vi.mock('../../hooks/useSpeakingDetector.tsx');
-vi.mock('../../hooks/useLayoutManager.tsx');
-vi.mock('../../hooks/useSessionContext.tsx');
-vi.mock('../../hooks/useActiveSpeaker.tsx');
-vi.mock('../../hooks/useScreenShare.tsx');
-vi.mock('../../hooks/useToolbarButtons');
-vi.mock('../../Context/PublisherProvider/usePublisherOptions');
+vi.mock('@hooks/useDevices.tsx');
+vi.mock('@hooks/usePublisherContext.tsx');
+vi.mock('@hooks/useUserContext.tsx');
+vi.mock('@hooks/useSpeakingDetector.tsx');
+vi.mock('@hooks/useLayoutManager.tsx');
+vi.mock('@hooks/useSessionContext.tsx');
+vi.mock('@hooks/useActiveSpeaker.tsx');
+vi.mock('@hooks/useScreenShare.tsx');
+vi.mock('@hooks/useToolbarButtons');
+vi.mock('@Context/PublisherProvider/usePublisherOptions');
const mockUseDevices = useDevices as Mock<
[],
@@ -112,16 +94,6 @@ const mockUseToolbarButtons = useToolbarButtons as Mock<
UseToolbarButtons
>;
-const MeetingRoomWithProviders = () => (
-
-
-
-
-
-
-
-);
-
const createSubscriberWrapper = (id: string): SubscriberWrapper => {
const mockSubscriber = {
id,
@@ -217,39 +189,39 @@ describe('MeetingRoom', () => {
});
it('should render', () => {
- render();
+ render();
const meetingRoom = screen.getByTestId('meetingRoom');
expect(meetingRoom).not.toBeNull();
});
it('renders the small viewport header bar if it is on a small tab or device', () => {
(mui.useMediaQuery as Mock).mockReturnValue(true);
- render();
+ render();
expect(screen.getByTestId('smallViewportHeader')).not.toBeNull();
});
it('does not render the small viewport header bar if it is on desktop', () => {
// we do not need to mock the small port view value here given we already do it in beforeEach
- render();
+ render();
expect(screen.queryByTestId('smallViewportHeader')).toBeNull();
});
it('should call joinRoom on render only once', () => {
- const { rerender } = render();
+ const { rerender } = render();
expect(sessionContext.joinRoom).toHaveBeenCalledWith('test-room-name');
expect(sessionContext.joinRoom).toHaveBeenCalledTimes(1);
- rerender();
- rerender();
- rerender();
- rerender();
+ rerender();
+ rerender();
+ rerender();
+ rerender();
expect(sessionContext.joinRoom).toHaveBeenCalledTimes(1);
});
it('should call publish after connected', () => {
- const { rerender } = render();
+ const { rerender } = render();
expect(sessionContext.joinRoom).toHaveBeenCalledWith('test-room-name');
sessionContext.connected = true;
- rerender();
+ rerender();
expect(publisherContext.initializeLocalPublisher).toHaveBeenCalledTimes(1);
expect(publisherContext.publish).toHaveBeenCalledTimes(1);
});
@@ -257,19 +229,19 @@ describe('MeetingRoom', () => {
it('should display publisher', () => {
sessionContext.connected = true;
publisherContext.publisher = mockPublisher;
- const { rerender } = render();
- rerender();
+ const { rerender } = render();
+ rerender();
expect(screen.getByTestId('publisher-container')).toBeInTheDocument();
});
it('should display spinner until session is connected', () => {
sessionContext.connected = false;
publisherContext.publisher = mockPublisher;
- const { rerender } = render();
- rerender();
+ const { rerender } = render();
+ rerender();
expect(screen.getByTestId('progress-spinner')).toBeInTheDocument();
sessionContext.connected = true;
- rerender();
+ rerender();
expect(screen.queryByTestId('progress-spinner')).not.toBeInTheDocument();
});
@@ -286,8 +258,8 @@ describe('MeetingRoom', () => {
createSubscriberWrapper('sub7'),
];
publisherContext.publisher = mockPublisher;
- const { rerender } = render();
- rerender();
+ const { rerender } = render();
+ rerender();
expect(screen.getByTestId('subscriber-container-sub1')).toBeVisible();
expect(screen.getByTestId('subscriber-container-sub2')).toBeVisible();
expect(screen.getByTestId('subscriber-container-sub3')).toBeVisible();
@@ -306,10 +278,10 @@ describe('MeetingRoom', () => {
.map((_s, index) => createSubscriberWrapper(`sub${index + 1}`));
sessionContext.subscriberWrappers = [sub1];
publisherContext.publisher = mockPublisher;
- const { rerender } = render();
+ const { rerender } = render();
sessionContext.subscriberWrappers = [sub2, sub1];
- rerender();
+ rerender();
const getSubIdsInRenderOrder = () =>
screen.getAllByTestId('subscriber-container', { exact: false }).map((element) => element?.id);
@@ -318,7 +290,7 @@ describe('MeetingRoom', () => {
expect(getSubIdsInRenderOrder()).toEqual(['sub1', 'sub2']);
sessionContext.subscriberWrappers = [sub3, sub2, sub1];
- rerender();
+ rerender();
// sub1 and sub2 joined first so should stay in position ahead of sub3
expect(getSubIdsInRenderOrder()).toEqual(['sub1', 'sub2', 'sub3']);
@@ -327,10 +299,10 @@ describe('MeetingRoom', () => {
it('should display chat unread number', () => {
sessionContext.connected = true;
publisherContext.publisher = mockPublisher;
- const { rerender } = render();
- rerender();
+ const { rerender } = render();
+ rerender();
sessionContext.unreadCount = 4;
- rerender();
+ rerender();
expect(screen.queryAllByTestId('chat-button-unread-count')[0]).toHaveTextContent('4');
});
@@ -339,7 +311,7 @@ describe('MeetingRoom', () => {
publisherContext.isVideoEnabled = false;
publisherContext.quality = 'poor';
- render();
+ render();
const connectionAlert = screen.queryByText(
'Please check your connectivity. Your video may be disabled to improve the user experience'
@@ -350,7 +322,7 @@ describe('MeetingRoom', () => {
it('should be displayed when publishing video', () => {
publisherContext.isVideoEnabled = true;
publisherContext.quality = 'poor';
- render();
+ render();
const connectionAlert = screen.getByText(
'Please check your connectivity. Your video may be disabled to improve the user experience'
@@ -361,7 +333,7 @@ describe('MeetingRoom', () => {
it('should be hidden when user stops publishing video', () => {
publisherContext.isVideoEnabled = true;
publisherContext.quality = 'poor';
- const { rerender } = render();
+ const { rerender } = render();
const connectionAlert = screen.queryByText(
'Please check your connectivity. Your video may be disabled to improve the user experience'
@@ -369,7 +341,7 @@ describe('MeetingRoom', () => {
expect(connectionAlert).toBeInTheDocument();
publisherContext.isVideoEnabled = false;
- rerender();
+ rerender();
expect(connectionAlert).not.toBeInTheDocument();
});
});
@@ -381,7 +353,7 @@ describe('MeetingRoom', () => {
"We're having trouble connecting you with others in the meeting room. Please check your network and try again.",
};
publisherContext.publishingError = publishingBlockedError;
- render();
+ render();
expect(mockedNavigate).toHaveBeenCalledOnce();
expect(mockedNavigate).toHaveBeenCalledWith('/goodbye', {
@@ -394,3 +366,16 @@ describe('MeetingRoom', () => {
});
});
});
+
+function render(ui: ReactElement) {
+ const { AppConfigWrapper } = makeAppConfigProviderWrapper();
+
+ const composeWrapper = composeProviders(
+ AppConfigWrapper,
+ UserProvider,
+ SessionProvider,
+ PublisherProvider
+ );
+
+ return renderBase(ui, { wrapper: composeWrapper });
+}
diff --git a/frontend/src/pages/WaitingRoom/WaitingRoom.spec.tsx b/frontend/src/pages/WaitingRoom/WaitingRoom.spec.tsx
index d91bde26..54f5d3c8 100644
--- a/frontend/src/pages/WaitingRoom/WaitingRoom.spec.tsx
+++ b/frontend/src/pages/WaitingRoom/WaitingRoom.spec.tsx
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi, Mock, beforeAll, afterAll } from 'vitest';
-import { act, render, screen, waitFor } from '@testing-library/react';
-import { ReactNode } from 'react';
+import { act, render as renderBase, screen } from '@testing-library/react';
+import { ReactElement, ReactNode } from 'react';
import { Publisher } from '@vonage/client-sdk-video';
import EventEmitter from 'events';
import userEvent from '@testing-library/user-event';
@@ -18,6 +18,7 @@ import usePermissions from '@hooks/usePermissions';
import { DEVICE_ACCESS_STATUS } from '@utils/constants';
import waitUntilPlaying from '@utils/waitUntilPlaying';
import { BackgroundPublisherContextType } from '@Context/BackgroundPublisherProvider';
+import { AppConfigProviderWrapperOptions, makeAppConfigProviderWrapper } from '@test/providers';
import WaitingRoom from './WaitingRoom';
const mockedNavigate = vi.fn();
@@ -125,23 +126,35 @@ describe('WaitingRoom', () => {
});
it('should display a video loading element on entering', () => {
- render();
+ render(, {
+ appConfigOptions: {
+ value: {
+ isAppConfigLoaded: false,
+ videoSettings: {
+ allowCameraControl: true,
+ },
+ },
+ },
+ });
+
const videoLoadingElement = screen.getByTestId('VideoLoading');
expect(videoLoadingElement).toBeVisible();
});
it('should eventually display a preview publisher', async () => {
- const { rerender } = render();
- act(() => {
- // After the preview publisher initializes.
- previewPublisherContext.publisher = mockPublisher;
- previewPublisherContext.publisherVideoElement = mockPublisherVideoElement;
- previewPublisherContext.isVideoEnabled = true;
+ // After the preview publisher initializes.
+ previewPublisherContext.publisher = mockPublisher;
+ previewPublisherContext.publisherVideoElement = mockPublisherVideoElement;
+ previewPublisherContext.isVideoEnabled = true;
+
+ const { rerender, container } = render();
+
+ await act(() => {
+ rerender();
});
- rerender();
- const previewPublisher = screen.getByTitle('publisher-preview');
- await waitFor(() => expect(previewPublisher).toBeVisible());
+ expect(container.querySelector('[data-video-container]')).toBeVisible();
+ expect(screen.getByTitle('publisher-preview')).toBeVisible();
});
it('should call destroyPublisher when navigating away from waiting room', async () => {
@@ -197,3 +210,14 @@ function getLocationMock() {
return { locationBackUp: location, locationMock };
}
+
+function render(
+ ui: ReactElement,
+ options?: {
+ appConfigOptions?: AppConfigProviderWrapperOptions;
+ }
+) {
+ const { AppConfigWrapper } = makeAppConfigProviderWrapper(options?.appConfigOptions);
+
+ return renderBase(ui, { wrapper: AppConfigWrapper });
+}
diff --git a/frontend/src/test/providers/index.ts b/frontend/src/test/providers/index.ts
new file mode 100644
index 00000000..6b1e80f6
--- /dev/null
+++ b/frontend/src/test/providers/index.ts
@@ -0,0 +1,26 @@
+export {
+ default as makeAppConfigProviderWrapper,
+ type AppConfigProviderWrapperOptions,
+} from './makeAppConfigProviderWrapper';
+
+export {
+ default as makeSessionProviderWrapper,
+ type SessionProviderWrapperOptions,
+} from './makeSessionProviderWrapper';
+
+export {
+ default as makeUserProviderWrapper,
+ type UserProviderWrapperOptions,
+} from './makeUserProviderWrapper';
+
+/**
+ * TODO: We still need to create provider wrappers for the following contexts:
+ *
+ * AudioOutputProvider,
+ * BackgroundPublisherProvider,
+ * PreviewPublisherProvider,
+ * PublisherProvider,
+ * RoomProvider
+ *
+ * Right now we are mocking all those context which downgrades the quality of our tests.
+ */
diff --git a/frontend/src/test/providers/makeAppConfigProviderWrapper.ts b/frontend/src/test/providers/makeAppConfigProviderWrapper.ts
new file mode 100644
index 00000000..30467d95
--- /dev/null
+++ b/frontend/src/test/providers/makeAppConfigProviderWrapper.ts
@@ -0,0 +1,36 @@
+import { DeepPartial } from '@app-types/index';
+import appConfig, { type AppConfig } from '@Context/AppConfig';
+import defaultAppConfig from '@Context/AppConfig/helpers/defaultAppConfig';
+import mergeAppConfigs from '@Context/AppConfig/helpers/mergeAppConfigs';
+
+export type AppConfigProviderWrapperOptions = {
+ value?: DeepPartial;
+};
+
+/**
+ * Creates wrapper for the AppConfigProvider context.
+ * Allows overriding context values via options and accessing the context value.
+ * @param {object} appConfigOptions - The wrapper options.
+ * @returns {object} The AppConfigProvider wrapper and context getter.
+ */
+function makeAppConfigProviderWrapper(appConfigOptions?: AppConfigProviderWrapperOptions) {
+ const initialState = mergeAppConfigs({
+ previous: defaultAppConfig,
+ updates: {
+ /**
+ * This flag marks the app config as loaded to prevent fetching it during tests.
+ */
+ isAppConfigLoaded: true,
+ ...appConfigOptions?.value,
+ },
+ });
+
+ const { wrapper: AppConfigWrapper, context: appConfigContext } =
+ appConfig.Provider.makeProviderWrapper({
+ value: initialState,
+ });
+
+ return { AppConfigWrapper, appConfigContext };
+}
+
+export default makeAppConfigProviderWrapper;
diff --git a/frontend/src/test/providers/makeGenericProviderWrapper.tsx b/frontend/src/test/providers/makeGenericProviderWrapper.tsx
new file mode 100644
index 00000000..803c04e0
--- /dev/null
+++ b/frontend/src/test/providers/makeGenericProviderWrapper.tsx
@@ -0,0 +1,85 @@
+/* eslint-disable no-underscore-dangle */
+/* eslint-disable jsdoc/no-undefined-types */
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import React, { PropsWithChildren, useContext } from 'react';
+
+export type ContextInterceptorCallback> = (
+ value: React.ContextType
+) => void;
+
+export type ContextCreatedCallback> = (
+ value: React.ContextType
+) => void;
+
+export type GenericWrapperOptions<
+ TProvider extends React.FunctionComponent>,
+ TContext extends React.Context,
+> = {
+ __interceptor?: ContextInterceptorCallback;
+ __onCreated?: ContextCreatedCallback;
+} & Omit[0], 'children'>;
+
+/**
+ * Creates a generic provider wrapper for testing context providers.
+ * The wrapper includes an interceptor to capture context values.
+ * @param {React.FunctionComponent} ContextProvider - The context provider component.
+ * @param {React.Context} context - The context to capture values from.
+ * @param {object} [options] - The wrapper options.
+ * @param {ContextInterceptorCallback} [options.__interceptor] - Callback called on each render with the current context value.
+ * @param {ContextCreatedCallback} [options.__onCreated] - Callback called once when the context is first created.
+ * @param {...any} [options.props] - Any additional props are passed through to the ContextProvider.
+ * @returns {[React.FunctionComponent, { current: any | undefined }]} The provider wrapper and context value getter.
+ */
+function makeGenericProviderWrapper<
+ TProvider extends React.FunctionComponent>,
+ TContext extends React.Context,
+>(
+ ContextProvider: TProvider,
+ context: TContext,
+ options?: GenericWrapperOptions
+) {
+ type ContextValue = React.ContextType;
+
+ /**
+ * Get the last context value captured by the Interceptor.
+ * @returns {ContextValue | null} The last context value or null if not yet captured.
+ */
+ const contextResult = {
+ current: undefined as ContextValue,
+ };
+
+ let onCreated = options?.__onCreated ?? null;
+
+ const Interceptor = () => {
+ const contextValue = useContext(context);
+
+ contextResult.current = contextValue;
+
+ onCreated?.(contextValue);
+ onCreated = null;
+
+ options?.__interceptor?.(contextValue);
+
+ return null;
+ };
+
+ /**
+ * Wrapper component for the context provider.
+ * Contains an interceptor to capture context values.
+ * @param {object} props - The component props.
+ * @param {React.ReactNode} props.children - The child components.
+ * @returns {React.ReactElement} The provider wrapper component.
+ */
+ const ProviderWrapper = ({ children }: PropsWithChildren) => {
+ return (
+ [0])}>
+
+ {children}
+
+ );
+ };
+
+ return [ProviderWrapper, contextResult] as const;
+}
+
+export default makeGenericProviderWrapper;
diff --git a/frontend/src/test/providers/makeSessionProviderWrapper.ts b/frontend/src/test/providers/makeSessionProviderWrapper.ts
new file mode 100644
index 00000000..5b46a110
--- /dev/null
+++ b/frontend/src/test/providers/makeSessionProviderWrapper.ts
@@ -0,0 +1,57 @@
+import SessionProvider, { SessionContext } from '@Context/SessionProvider/session';
+import composeProviders from '@utils/composeProviders';
+import makeGenericProviderWrapper, { GenericWrapperOptions } from './makeGenericProviderWrapper';
+import makeUserProviderWrapper, { UserProviderWrapperOptions } from './makeUserProviderWrapper';
+import makeAppConfigProviderWrapper, {
+ AppConfigProviderWrapperOptions,
+} from './makeAppConfigProviderWrapper';
+
+export type SessionProviderWrapperOptions = GenericWrapperOptions<
+ typeof SessionProvider,
+ typeof SessionContext
+> & {
+ appConfigOptions?: AppConfigProviderWrapperOptions;
+ userOptions?: UserProviderWrapperOptions;
+};
+
+/**
+ * Creates wrapper for the SessionProvider context.
+ * The wrapper includes:
+ * - AppConfigProvider: you can override its options via appConfigOptions
+ * - UserProvider: you can override its options via userOptions
+ * - SessionProvider: you can override its options via the rest of the options
+ * @param {object} options - The wrapper options.
+ * @param {AppConfigProviderWrapperOptions} [options.appConfigOptions] - Options for the AppConfigProvider wrapper.
+ * @param {UserProviderWrapperOptions} [options.userOptions] - Options for the UserProvider wrapper.
+ * @returns {object} The SessionProvider wrapper and context getters.
+ */
+function makeSessionProviderWrapper({
+ appConfigOptions,
+ userOptions,
+ ...sessionOptions
+}: SessionProviderWrapperOptions = {}) {
+ const { AppConfigWrapper, appConfigContext } = makeAppConfigProviderWrapper(appConfigOptions);
+
+ const { UserProviderWrapper, userContext } = makeUserProviderWrapper(userOptions);
+
+ const [SessionProviderWrapper, sessionContext] = makeGenericProviderWrapper(
+ SessionProvider,
+ SessionContext,
+ sessionOptions
+ );
+
+ const composeWrapper = composeProviders(
+ AppConfigWrapper,
+ UserProviderWrapper,
+ SessionProviderWrapper
+ );
+
+ return {
+ SessionProviderWrapper: composeWrapper,
+ sessionContext,
+ userContext,
+ appConfigContext,
+ };
+}
+
+export default makeSessionProviderWrapper;
diff --git a/frontend/src/test/providers/makeUserProviderWrapper.ts b/frontend/src/test/providers/makeUserProviderWrapper.ts
new file mode 100644
index 00000000..71ead467
--- /dev/null
+++ b/frontend/src/test/providers/makeUserProviderWrapper.ts
@@ -0,0 +1,25 @@
+import UserProvider, { UserContext } from '@Context/user';
+import makeGenericProviderWrapper, { GenericWrapperOptions } from './makeGenericProviderWrapper';
+
+export type UserProviderWrapperOptions = GenericWrapperOptions<
+ typeof UserProvider,
+ typeof UserContext
+>;
+
+/**
+ * Creates wrapper for the UserProvider context.
+ * Allows overriding context values via options and accessing the context value.
+ * @param {object} options - The wrapper options.
+ * @returns {object} The UserProvider wrapper and context getter.
+ */
+function makeUserProviderWrapper(options?: UserProviderWrapperOptions) {
+ const [UserProviderWrapper, userContext] = makeGenericProviderWrapper(
+ UserProvider,
+ UserContext,
+ options
+ );
+
+ return { UserProviderWrapper, userContext };
+}
+
+export default makeUserProviderWrapper;
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index 7736072a..ac456c1e 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -1 +1,5 @@
export * from './room';
+
+export type DeepPartial = {
+ [K in keyof T]?: T[K] extends object ? DeepPartial : T[K];
+};
diff --git a/frontend/src/utils/composeProviders.tsx b/frontend/src/utils/composeProviders.tsx
new file mode 100644
index 00000000..9f1ec9a4
--- /dev/null
+++ b/frontend/src/utils/composeProviders.tsx
@@ -0,0 +1,17 @@
+import React from 'react';
+
+type ProviderProps = { children: React.ReactNode };
+type ProviderComponent = React.ComponentType;
+
+/**
+ * Composes multiple context providers into a single provider component.
+ * @param {...ProviderComponent} providers - The provider components to compose.
+ * @returns {ProviderComponent} - A single provider component that nests the given providers.
+ */
+function composeProviders(...providers: ProviderComponent[]): ProviderComponent {
+ return ({ children }) => {
+ return providers.reduceRight((acc, Provider) => {acc}, children);
+ };
+}
+
+export default composeProviders;
diff --git a/package.json b/package.json
index 84c69839..84f53ece 100644
--- a/package.json
+++ b/package.json
@@ -21,7 +21,7 @@
"dev": "yarn && concurrently 'yarn dev:frontend' 'yarn dev:backend'",
"docs": "yarn workspace frontend docs",
"docs:watch": "yarn workspace frontend docs:watch",
- "lint": "ESLINT_USE_FLAT_CONFIG=false yarn eslint . --ext .ts,.tsx",
+ "lint": "ESLINT_USE_FLAT_CONFIG=false yarn eslint . --ext .ts,.tsx --max-warnings 0",
"lint:filenames": "./scripts/lintFileNames.sh",
"lint:fix": "prettier --log-level warn --write \"**/*.{ts,tsx,js,json,md,yml,yaml}\" && yarn lint --fix",
"prepare": "husky install",
diff --git a/yarn.lock b/yarn.lock
index d24cb2e8..0c727792 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3552,6 +3552,11 @@ cjs-module-lexer@^1.0.0:
resolved "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.3.1.tgz"
integrity sha512-a3KdPAANPbNE4ZUv9h6LckSl9zLsYOP4MBmhIPkRaeyybt+r4UghLvq+xw/YwUcC1gqylCkL4rdVs3Lwupjm4Q==
+classnames@^2.5.1:
+ version "2.5.1"
+ resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.5.1.tgz#ba774c614be0f016da105c858e7159eae8e7687b"
+ integrity sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==
+
clear-module@^4.1.2:
version "4.1.2"
resolved "https://registry.npmjs.org/clear-module/-/clear-module-4.1.2.tgz"
@@ -6288,6 +6293,11 @@ json-stable-stringify-without-jsonify@^1.0.1:
resolved "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz"
integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==
+json-storage-formatter@^2.0.9:
+ version "2.0.9"
+ resolved "https://registry.yarnpkg.com/json-storage-formatter/-/json-storage-formatter-2.0.9.tgz#f86a7fd2375c370e40c30be630a097ae3a93f3d8"
+ integrity sha512-8UsiveogvIJp+Bf1h5yTKPsLbt5tgQKmuh7tFru9Z5js2BjrJMHsdqO+KOWJrvaaj0D1TexMLGg1/q0yDrwgQw==
+
json-stringify-safe@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb"
@@ -7667,6 +7677,18 @@ react-dom@^19.1.0:
dependencies:
scheduler "^0.26.0"
+react-global-state-hooks@^14.0.2:
+ version "14.0.2"
+ resolved "https://registry.yarnpkg.com/react-global-state-hooks/-/react-global-state-hooks-14.0.2.tgz#0a64749f386a962b44466637a756d1312c2ef4d3"
+ integrity sha512-GPbj3f/NKOb8MaqdVvtQf1R6cvOznxDyC1OHLBQf7nefVpvPrfvFFDkx83wsT5bS2VsUQQ7qMWcpEMmaSdMcpA==
+ dependencies:
+ react-hooks-global-states "^14.0.2"
+
+react-hooks-global-states@^14.0.2:
+ version "14.0.2"
+ resolved "https://registry.yarnpkg.com/react-hooks-global-states/-/react-hooks-global-states-14.0.2.tgz#7c87acbe97300664e6924ef851dc222f73516445"
+ integrity sha512-6o4NfaweJ5ryLUUmQWZKnIwHaoQWs6AtRT2DCZZ3eYiX3Dco2uZkAlJBfDyYFY9VbBznRetcrMXXCeBJYTVaAA==
+
react-i18next@^15.6.1:
version "15.6.1"
resolved "https://registry.yarnpkg.com/react-i18next/-/react-i18next-15.6.1.tgz#a2747bed7768faef28fa28de32ff3811b2459c20"