diff --git a/config.example.json b/config.example.json new file mode 100644 index 00000000..908ee883 --- /dev/null +++ b/config.example.json @@ -0,0 +1,26 @@ +{ + "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 + } +} diff --git a/frontend/src/App.spec.tsx b/frontend/src/App.spec.tsx index 6e23a5ca..3ba71d26 100644 --- a/frontend/src/App.spec.tsx +++ b/frontend/src/App.spec.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react'; import { describe, it, expect, vi, afterEach } from 'vitest'; -import React from 'react'; +import { PropsWithChildren } from 'react'; import App from './App'; // Mock the page components to make text assertions easy @@ -13,22 +13,27 @@ vi.mock('./pages/UnsupportedBrowserPage', () => ({ // Mock context providers and wrappers vi.mock('./Context/PreviewPublisherProvider', () => ({ __esModule: true, - PreviewPublisherProvider: ({ children }: React.PropsWithChildren) => children, - default: ({ children }: React.PropsWithChildren) => children, + PreviewPublisherProvider: ({ children }: PropsWithChildren) => children, + default: ({ children }: PropsWithChildren) => children, })); vi.mock('./Context/PublisherProvider', () => ({ __esModule: true, - PublisherProvider: ({ children }: React.PropsWithChildren) => children, - default: ({ children }: React.PropsWithChildren) => children, + PublisherProvider: ({ children }: PropsWithChildren) => children, + default: ({ children }: PropsWithChildren) => children, })); vi.mock('./Context/SessionProvider/session', () => ({ - default: ({ children }: React.PropsWithChildren) => children, + default: ({ children }: PropsWithChildren) => children, })); vi.mock('./components/RedirectToWaitingRoom', () => ({ - default: ({ children }: React.PropsWithChildren) => children, + default: ({ children }: PropsWithChildren) => children, })); vi.mock('./Context/RoomContext', () => ({ - default: ({ children }: React.PropsWithChildren) => children, + default: ({ children }: PropsWithChildren) => children, +})); +vi.mock('./Context/ConfigProvider', () => ({ + __esModule: true, + ConfigContextProvider: ({ children }: PropsWithChildren) => children, + default: ({ children }: PropsWithChildren) => children, })); afterEach(() => { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d25df311..3d48b626 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,7 +11,6 @@ import { PublisherProvider } from './Context/PublisherProvider'; import RedirectToWaitingRoom from './components/RedirectToWaitingRoom'; import UnsupportedBrowserPage from './pages/UnsupportedBrowserPage'; import RoomContext from './Context/RoomContext'; -import { BackgroundPublisherProvider } from './Context/BackgroundPublisherProvider'; const App = () => { return ( @@ -21,11 +20,9 @@ const App = () => { - - - - + + + } /> { - - - + diff --git a/frontend/src/Context/BackgroundPublisherProvider/useBackgroundPublisher/useBackgroundPublisher.spec.tsx b/frontend/src/Context/BackgroundPublisherProvider/useBackgroundPublisher/useBackgroundPublisher.spec.tsx index 66a4b232..3602dc35 100644 --- a/frontend/src/Context/BackgroundPublisherProvider/useBackgroundPublisher/useBackgroundPublisher.spec.tsx +++ b/frontend/src/Context/BackgroundPublisherProvider/useBackgroundPublisher/useBackgroundPublisher.spec.tsx @@ -1,6 +1,11 @@ import { act, cleanup, renderHook } from '@testing-library/react'; import { afterAll, afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; -import { hasMediaProcessorSupport, initPublisher, Publisher } from '@vonage/client-sdk-video'; +import { + hasMediaProcessorSupport, + initPublisher, + Publisher, + PublisherProperties, +} from '@vonage/client-sdk-video'; import EventEmitter from 'events'; import useBackgroundPublisher from './useBackgroundPublisher'; import { UserContextType } from '../../user'; @@ -14,17 +19,20 @@ import { defaultVideoDevice, } from '../../../utils/mockData/device'; import { DEVICE_ACCESS_STATUS } from '../../../utils/constants'; +import usePublisherOptions from '../../PublisherProvider/usePublisherOptions'; vi.mock('@vonage/client-sdk-video'); vi.mock('../../../hooks/useUserContext.tsx'); vi.mock('../../../hooks/usePermissions.tsx'); vi.mock('../../../hooks/useDevices.tsx'); +vi.mock('../../PublisherProvider/usePublisherOptions'); const mockUseUserContext = useUserContext as Mock<[], UserContextType>; const mockUsePermissions = usePermissions as Mock<[], PermissionsHookType>; const mockUseDevices = useDevices as Mock< [], { allMediaDevices: AllMediaDevices; getAllMediaDevices: () => void } >; +const mockUsePublisherOptions = usePublisherOptions as Mock<[], PublisherProperties>; const defaultSettings = { publishAudio: false, @@ -66,6 +74,9 @@ describe('useBackgroundPublisher', () => { accessStatus: DEVICE_ACCESS_STATUS.PENDING, setAccessStatus: mockSetAccessStatus, }); + mockUsePublisherOptions.mockReturnValue({ + publishVideo: true, + }); }); afterEach(() => { diff --git a/frontend/src/Context/BackgroundPublisherProvider/useBackgroundPublisher/useBackgroundPublisher.tsx b/frontend/src/Context/BackgroundPublisherProvider/useBackgroundPublisher/useBackgroundPublisher.tsx index fb87a544..e0e9196d 100644 --- a/frontend/src/Context/BackgroundPublisherProvider/useBackgroundPublisher/useBackgroundPublisher.tsx +++ b/frontend/src/Context/BackgroundPublisherProvider/useBackgroundPublisher/useBackgroundPublisher.tsx @@ -60,14 +60,14 @@ const useBackgroundPublisher = (): BackgroundPublisherContextType => { >(); const { setAccessStatus, accessStatus } = usePermissions(); const backgroundPublisherRef = useRef(null); - const [isPublishing, setIsPublishing] = useState(false); + const [isPublishing, setIsPublishing] = useState(false); const initialBackgroundRef = useRef( user.defaultSettings.backgroundFilter ); const [backgroundFilter, setBackgroundFilter] = useState( user.defaultSettings.backgroundFilter ); - const [isVideoEnabled, setIsVideoEnabled] = useState(true); + const [isVideoEnabled, setIsVideoEnabled] = useState(true); const [localVideoSource, setLocalVideoSource] = useState(undefined); const deviceStoreRef = useRef(new DeviceStore()); @@ -180,6 +180,8 @@ const useBackgroundPublisher = (): BackgroundPublisherContextType => { videoFilter, resolution: '1280x720', videoSource, + publishAudio: false, + publishVideo: isVideoEnabled, }; backgroundPublisherRef.current = initPublisher(undefined, publisherOptions, (err: unknown) => { @@ -191,7 +193,7 @@ const useBackgroundPublisher = (): BackgroundPublisherContextType => { } }); addPublisherListeners(backgroundPublisherRef.current); - }, [addPublisherListeners]); + }, [addPublisherListeners, isVideoEnabled]); /** * Destroys the background publisher diff --git a/frontend/src/Context/ConfigProvider/index.tsx b/frontend/src/Context/ConfigProvider/index.tsx new file mode 100644 index 00000000..ef121cfb --- /dev/null +++ b/frontend/src/Context/ConfigProvider/index.tsx @@ -0,0 +1,33 @@ +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 new file mode 100644 index 00000000..f4720b90 --- /dev/null +++ b/frontend/src/Context/ConfigProvider/useConfig/index.tsx @@ -0,0 +1,4 @@ +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 new file mode 100644 index 00000000..ce52fe2a --- /dev/null +++ b/frontend/src/Context/ConfigProvider/useConfig/useConfig.spec.tsx @@ -0,0 +1,131 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import useConfig, { AppConfig } from './useConfig'; + +describe('useConfig', () => { + let nativeFetch: typeof global.fetch; + 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, + }, + }; + const consoleErrorSpy = vi.spyOn(console, 'error'); + const consoleInfoSpy = vi.spyOn(console, 'info'); + + beforeAll(() => { + nativeFetch = global.fetch; + }); + + beforeEach(() => { + global.fetch = vi.fn().mockResolvedValue({ + json: async () => ({}), + }); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + afterAll(() => { + global.fetch = nativeFetch; + }); + + 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, + }, + }; + global.fetch = vi.fn().mockResolvedValue({ + json: async () => mockConfig, + headers: { + get: () => 'application/json', + }, + }); + 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'); + global.fetch = vi.fn().mockRejectedValue(mockFetchError); + const { result } = renderHook(() => useConfig()); + + await waitFor(() => { + expect(result.current).toEqual(defaultConfig); + }); + expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading config:', expect.any(Error)); + }); + + it('falls back to defaultConfig if no config.json is found', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not Found', + headers: { + get: () => 'text/html', + }, + }); + const { result } = renderHook(() => useConfig()); + + await waitFor(() => { + expect(result.current).toEqual(defaultConfig); + }); + expect(consoleInfoSpy).toHaveBeenCalledWith('No valid JSON found, using default config'); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/Context/ConfigProvider/useConfig/useConfig.tsx b/frontend/src/Context/ConfigProvider/useConfig/useConfig.tsx new file mode 100644 index 00000000..41e42108 --- /dev/null +++ b/frontend/src/Context/ConfigProvider/useConfig/useConfig.tsx @@ -0,0 +1,134 @@ +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.tsx b/frontend/src/Context/PreviewPublisherProvider/usePreviewPublisher/usePreviewPublisher.tsx index c00b9ebb..75d90ae8 100644 --- a/frontend/src/Context/PreviewPublisherProvider/usePreviewPublisher/usePreviewPublisher.tsx +++ b/frontend/src/Context/PreviewPublisherProvider/usePreviewPublisher/usePreviewPublisher.tsx @@ -17,6 +17,7 @@ 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; @@ -66,6 +67,7 @@ export type PreviewPublisherContextType = { */ const usePreviewPublisher = (): PreviewPublisherContextType => { const { setUser, user } = useUserContext(); + const config = useConfigContext(); const { allMediaDevices, getAllMediaDevices } = useDevices(); const [publisherVideoElement, setPublisherVideoElement] = useState< HTMLVideoElement | HTMLObjectElement @@ -85,6 +87,7 @@ 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(() => { @@ -240,7 +243,7 @@ const usePreviewPublisher = (): PreviewPublisherContextType => { const publisherOptions: PublisherProperties = { insertDefaultUI: false, videoFilter, - resolution: '1280x720', + resolution: defaultResolution, audioSource, videoSource, }; @@ -254,7 +257,7 @@ const usePreviewPublisher = (): PreviewPublisherContextType => { } }); addPublisherListeners(publisherRef.current); - }, [addPublisherListeners]); + }, [addPublisherListeners, defaultResolution]); /** * Destroys the preview publisher diff --git a/frontend/src/Context/PublisherProvider/usePublisherOptions/usePublisherOptions.spec.tsx b/frontend/src/Context/PublisherProvider/usePublisherOptions/usePublisherOptions.spec.tsx index 32c402f0..9a184500 100644 --- a/frontend/src/Context/PublisherProvider/usePublisherOptions/usePublisherOptions.spec.tsx +++ b/frontend/src/Context/PublisherProvider/usePublisherOptions/usePublisherOptions.spec.tsx @@ -7,10 +7,14 @@ import usePublisherOptions from './usePublisherOptions'; import localStorageMock from '../../../utils/mockData/localStorageMock'; import DeviceStore from '../../../utils/DeviceStore'; import { setStorageItem, STORAGE_KEYS } from '../../../utils/storage'; +import useConfigContext from '../../../hooks/useConfigContext'; +import { ConfigContextType } from '../../ConfigProvider'; vi.mock('../../../hooks/useUserContext.tsx'); +vi.mock('../../../hooks/useConfigContext'); const mockUseUserContext = useUserContext as Mock<[], UserContextType>; +const mockUseConfigContext = useConfigContext as Mock<[], ConfigContextType>; const defaultSettings = { publishAudio: false, @@ -51,6 +55,8 @@ const mockUserContextWithCustomSettings = { describe('usePublisherOptions', () => { let enumerateDevicesMock: ReturnType; let deviceStore: DeviceStore; + let configContext: ConfigContextType; + beforeEach(async () => { enumerateDevicesMock = vi.fn(); vi.stubGlobal('navigator', { @@ -65,6 +71,16 @@ describe('usePublisherOptions', () => { deviceStore = new DeviceStore(); enumerateDevicesMock.mockResolvedValue([]); await deviceStore.init(); + configContext = { + audioSettings: { + allowAudioOnJoin: true, + }, + videoSettings: { + defaultResolution: '1280x720', + allowVideoOnJoin: true, + }, + } as Partial as ConfigContextType; + mockUseConfigContext.mockReturnValue(configContext); }); afterAll(() => { @@ -140,4 +156,30 @@ describe('usePublisherOptions', () => { }); }); }); + + describe('configurable features', () => { + it('should disable audio publishing when allowAudioOnJoin is false', async () => { + configContext.audioSettings.allowAudioOnJoin = false; + const { result } = renderHook(() => usePublisherOptions()); + 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()); + await waitFor(() => { + expect(result.current?.publishVideo).toBe(false); + }); + }); + + it('should configure resolution from config', async () => { + configContext.videoSettings.defaultResolution = '640x480'; + const { result } = renderHook(() => usePublisherOptions()); + await waitFor(() => { + expect(result.current?.resolution).toBe('640x480'); + }); + }); + }); }); diff --git a/frontend/src/Context/PublisherProvider/usePublisherOptions/usePublisherOptions.tsx b/frontend/src/Context/PublisherProvider/usePublisherOptions/usePublisherOptions.tsx index eb4b2d1a..2ab22c74 100644 --- a/frontend/src/Context/PublisherProvider/usePublisherOptions/usePublisherOptions.tsx +++ b/frontend/src/Context/PublisherProvider/usePublisherOptions/usePublisherOptions.tsx @@ -8,6 +8,7 @@ import { import useUserContext from '../../../hooks/useUserContext'; import getInitials from '../../../utils/getInitials'; import DeviceStore from '../../../utils/DeviceStore'; +import useConfigContext from '../../../hooks/useConfigContext'; /** * React hook to get PublisherProperties combining default options and options set in UserContext @@ -16,8 +17,11 @@ import DeviceStore from '../../../utils/DeviceStore'; const usePublisherOptions = (): PublisherProperties | null => { const { user } = useUserContext(); + const config = useConfigContext(); const [publisherOptions, setPublisherOptions] = useState(null); const deviceStoreRef = useRef(null); + const { defaultResolution, allowVideoOnJoin } = config.videoSettings; + const { allowAudioOnJoin } = config.audioSettings; useEffect(() => { const setOptions = async () => { @@ -53,9 +57,9 @@ const usePublisherOptions = (): PublisherProperties | null => { initials, insertDefaultUI: false, name, - publishAudio: !!publishAudio, - publishVideo: !!publishVideo, - resolution: '1280x720', + publishAudio: allowAudioOnJoin && publishAudio, + publishVideo: allowVideoOnJoin && publishVideo, + resolution: defaultResolution, audioFilter, videoFilter, videoSource, @@ -64,7 +68,7 @@ const usePublisherOptions = (): PublisherProperties | null => { }; setOptions(); - }, [user.defaultSettings]); + }, [allowAudioOnJoin, defaultResolution, allowVideoOnJoin, user.defaultSettings]); return publisherOptions; }; diff --git a/frontend/src/Context/RoomContext.tsx b/frontend/src/Context/RoomContext.tsx index ee4c459f..31cce11c 100644 --- a/frontend/src/Context/RoomContext.tsx +++ b/frontend/src/Context/RoomContext.tsx @@ -3,19 +3,25 @@ import { ReactElement } from 'react'; import RedirectToUnsupportedBrowserPage from '../components/RedirectToUnsupportedBrowserPage'; import { AudioOutputProvider } from './AudioOutputProvider'; import UserProvider from './user'; +import { ConfigProvider } from './ConfigProvider'; +import { BackgroundPublisherProvider } from './BackgroundPublisherProvider'; /** * Wrapper for all of the contexts used by the waiting room and the meeting room. * @returns {ReactElement} The context. */ const RoomContext = (): ReactElement => ( - - - - - - - + + + + + + + + + + + ); export default RoomContext; diff --git a/frontend/src/Context/SessionProvider/session.spec.tsx b/frontend/src/Context/SessionProvider/session.spec.tsx index 68725136..51a6ef99 100644 --- a/frontend/src/Context/SessionProvider/session.spec.tsx +++ b/frontend/src/Context/SessionProvider/session.spec.tsx @@ -20,6 +20,15 @@ vi.mock('../../utils/constants', () => ({ MAX_PIN_COUNT_DESKTOP: 1, })); vi.mock('../../api/fetchCredentials'); +vi.mock('../../hooks/useConfigContext', () => { + return { + default: () => ({ + meetingRoomSettings: { + defaultLayoutMode: 'active-speaker', + }, + }), + }; +}); const mockFetchCredentials = fetchCredentials as Mock; diff --git a/frontend/src/Context/SessionProvider/session.tsx b/frontend/src/Context/SessionProvider/session.tsx index 9d805ce4..0cc7e7cf 100644 --- a/frontend/src/Context/SessionProvider/session.tsx +++ b/frontend/src/Context/SessionProvider/session.tsx @@ -13,6 +13,7 @@ import { 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 { @@ -22,6 +23,7 @@ import { StreamPropertyChangedEvent, SubscriberAudioLevelUpdatedEvent, SubscriberWrapper, + LayoutMode, } from '../../types/session'; import useChat from '../../hooks/useChat'; import { ChatMessageType } from '../../types/chat'; @@ -36,8 +38,6 @@ import useEmoji, { EmojiWrapper } from '../../hooks/useEmoji'; export type { ChatMessageType } from '../../types/chat'; -export type LayoutMode = 'grid' | 'active-speaker'; - export type SessionContextType = { vonageVideoClient: null | VonageVideoClient; disconnect: null | (() => void); @@ -128,12 +128,15 @@ 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 [lastStreamUpdate, setLastStreamUpdate] = useState(null); const vonageVideoClient = useRef(null); const [reconnecting, setReconnecting] = useState(false); const [subscriberWrappers, setSubscriberWrappers] = useState([]); const [ownCaptions, setOwnCaptions] = useState(null); - const [layoutMode, setLayoutMode] = useState('active-speaker'); + const [layoutMode, setLayoutMode] = useState( + config.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 c902a3ca..684c1f4e 100644 --- a/frontend/src/Context/tests/RoomContext.spec.tsx +++ b/frontend/src/Context/tests/RoomContext.spec.tsx @@ -1,6 +1,7 @@ import { render, screen } from '@testing-library/react'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; +import { PropsWithChildren } from 'react'; import RoomContext from '../RoomContext'; import useUserContext from '../../hooks/useUserContext'; import { UserContextType } from '../user'; @@ -10,6 +11,15 @@ import { nativeDevices } from '../../utils/mockData/device'; 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, +})); const mockUseUserContext = useUserContext as Mock<[], UserContextType>; const mockUseAudioOutputContext = useAudioOutputContext as Mock<[], AudioOutputContextType>; diff --git a/frontend/src/components/HiddenParticipantsTile/HiddenParticipantsTile.spec.tsx b/frontend/src/components/HiddenParticipantsTile/HiddenParticipantsTile.spec.tsx index 00fc96db..ac21a4c1 100644 --- a/frontend/src/components/HiddenParticipantsTile/HiddenParticipantsTile.spec.tsx +++ b/frontend/src/components/HiddenParticipantsTile/HiddenParticipantsTile.spec.tsx @@ -1,11 +1,24 @@ import { fireEvent, render, screen } from '@testing-library/react'; -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach, Mock, afterEach } from 'vitest'; import userEvent from '@testing-library/user-event'; import { Subscriber } from '@vonage/client-sdk-video'; import { SubscriberWrapper } from '../../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 = [ { @@ -28,9 +41,20 @@ describe('HiddenParticipantsTile', () => { }, ]; - it('should display two hidden participants', async () => { - const mockFn = vi.fn(); + 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, { @@ -53,13 +77,7 @@ describe('HiddenParticipantsTile', () => { }, ] as SubscriberWrapper[]; - render( - - ); + render(); const button = screen.getByTestId('hidden-participants'); expect(button).toBeInTheDocument(); @@ -71,21 +89,13 @@ describe('HiddenParticipantsTile', () => { expect(screen.getByText('JD')).toBeInTheDocument(); expect(screen.getByText('JS')).toBeInTheDocument(); - expect(mockFn).toHaveBeenCalled(); + expect(mockToggleParticipantList).toHaveBeenCalled(); }); it('should display one hidden participant because the other one is empty', async () => { - const mockFn = vi.fn(); - const currentHiddenSubscribers = [...hiddenSubscribers, {}] as SubscriberWrapper[]; - render( - - ); + render(); const button = screen.getByTestId('hidden-participants'); expect(button).toBeInTheDocument(); @@ -94,6 +104,23 @@ describe('HiddenParticipantsTile', () => { expect(screen.getByText('JD')).toBeInTheDocument(); expect(screen.queryByText('JS')).not.toBeInTheDocument(); - expect(mockFn).toHaveBeenCalled(); + expect(mockToggleParticipantList).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'); + expect(button).toBeInTheDocument(); + await userEvent.click(button); + + expect(mockToggleParticipantList).not.toHaveBeenCalled(); }); }); diff --git a/frontend/src/components/HiddenParticipantsTile/HiddenParticipantsTile.tsx b/frontend/src/components/HiddenParticipantsTile/HiddenParticipantsTile.tsx index e0239a16..7854bae5 100644 --- a/frontend/src/components/HiddenParticipantsTile/HiddenParticipantsTile.tsx +++ b/frontend/src/components/HiddenParticipantsTile/HiddenParticipantsTile.tsx @@ -4,11 +4,12 @@ import { Box } from 'opentok-layout-js'; import { SubscriberWrapper } from '../../types/session'; 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; hiddenSubscribers: SubscriberWrapper[]; - handleClick: () => void; }; /** * HiddenParticipantsTile Component @@ -23,17 +24,21 @@ export type HiddenParticipantsTileProps = { const HiddenParticipantsTile = ({ box, hiddenSubscribers, - handleClick, }: HiddenParticipantsTileProps): ReactElement => { + const { toggleParticipantList } = useSessionContext(); + const config = useConfigContext(); + const { showParticipantList } = config.meetingRoomSettings; const { height, width } = box; const diameter = Math.min(height, width) * 0.38; return ( - - + allowDeviceSelection && ( +
+
+ + + - - - + + + +
- + ) ); }; diff --git a/frontend/src/components/WaitingRoom/MicButton/MicButton.spec.tsx b/frontend/src/components/WaitingRoom/MicButton/MicButton.spec.tsx new file mode 100644 index 00000000..8d26d02d --- /dev/null +++ b/frontend/src/components/WaitingRoom/MicButton/MicButton.spec.tsx @@ -0,0 +1,60 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, Mock } from 'vitest'; +import MicButton from './MicButton'; +import useConfigContext from '../../../hooks/useConfigContext'; +import { ConfigContextType } from '../../../Context/ConfigProvider'; + +let isAudioEnabled = true; +const toggleAudioMock = vi.fn(); + +vi.mock('../../../hooks/usePreviewPublisherContext', () => { + return { + default: () => ({ + get isAudioEnabled() { + return isAudioEnabled; + }, + toggleAudio: toggleAudioMock, + }), + }; +}); + +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', () => { + render(); + expect(screen.getByTestId('MicIcon')).toBeInTheDocument(); + }); + + it('renders the mic off icon when audio is disabled', () => { + isAudioEnabled = false; + render(); + expect(screen.getByTestId('MicOffIcon')).toBeInTheDocument(); + }); + + it('calls toggleAudio when clicked', () => { + render(); + fireEvent.click(screen.getByRole('button')); + expect(toggleAudioMock).toHaveBeenCalled(); + }); + + it('is not rendered when allowMicrophoneControl is false', () => { + mockConfigContext.audioSettings.allowMicrophoneControl = false; + render(); + expect(screen.queryByTestId('MicIcon')).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/WaitingRoom/MicButton/MicButton.tsx b/frontend/src/components/WaitingRoom/MicButton/MicButton.tsx index 201c0a8d..9bd235d3 100644 --- a/frontend/src/components/WaitingRoom/MicButton/MicButton.tsx +++ b/frontend/src/components/WaitingRoom/MicButton/MicButton.tsx @@ -5,56 +5,61 @@ import { ReactElement } from 'react'; import { useTranslation } from 'react-i18next'; import usePreviewPublisherContext from '../../../hooks/usePreviewPublisherContext'; import VideoContainerButton from '../VideoContainerButton'; +import useConfigContext from '../../../hooks/useConfigContext'; /** * MicButton Component * * Toggles the user's microphone (published audio) and updates the icon accordingly. - * @returns {ReactElement} - The MicButton component. + * @returns {ReactElement | false} - The MicButton component. */ -const MicButton = (): ReactElement => { +const MicButton = (): ReactElement | false => { const { t } = useTranslation(); const { isAudioEnabled, toggleAudio } = usePreviewPublisherContext(); + const config = useConfigContext(); 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/hooks/useConfigContext.ts b/frontend/src/hooks/useConfigContext.ts new file mode 100644 index 00000000..94648ed3 --- /dev/null +++ b/frontend/src/hooks/useConfigContext.ts @@ -0,0 +1,18 @@ +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/locales/en.json b/frontend/src/locales/en.json index a54cb72f..40653a8f 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -46,6 +46,7 @@ "devices.audio.ariaLabel": "audio devices dropdown", "devices.audio.defaultLabel": "System Default", "devices.audio.disable": "Disable microphone", + "devices.audio.disabled": "Microphone control is disabled in this application", "devices.audio.enable": "Enable microphone", "devices.audio.microphone.ariaLabel": "toggle audio", "devices.audio.microphone.full": "Microphone", @@ -65,6 +66,7 @@ "devices.video.camera.state.off": "Disable camera", "devices.video.camera.full": "Camera", "devices.video.disable": "Disable video", + "devices.video.disabled": "Camera control is disabled in this application", "devices.video.enable": "Enable video", "emoji.ariaLabel": "open sendable emoji menu", "emoji.tooltip": "Express yourself", diff --git a/frontend/src/pages/MeetingRoom/MeetingRoom.spec.tsx b/frontend/src/pages/MeetingRoom/MeetingRoom.spec.tsx index 55314385..d788e32a 100644 --- a/frontend/src/pages/MeetingRoom/MeetingRoom.spec.tsx +++ b/frontend/src/pages/MeetingRoom/MeetingRoom.spec.tsx @@ -54,6 +54,27 @@ 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'); diff --git a/frontend/src/pages/MeetingRoom/MeetingRoom.tsx b/frontend/src/pages/MeetingRoom/MeetingRoom.tsx index b9986250..41f70448 100644 --- a/frontend/src/pages/MeetingRoom/MeetingRoom.tsx +++ b/frontend/src/pages/MeetingRoom/MeetingRoom.tsx @@ -40,7 +40,6 @@ const MeetingRoom = (): ReactElement => { initBackgroundLocalPublisher, publisher: backgroundPublisher, accessStatus, - destroyBackgroundPublisher, } = useBackgroundPublisherContext(); const { @@ -101,14 +100,7 @@ const MeetingRoom = (): ReactElement => { if (!backgroundPublisher) { initBackgroundLocalPublisher(); } - - return () => { - // Ensure we destroy the backgroundPublisher and release any media devices. - if (backgroundPublisher) { - destroyBackgroundPublisher(); - } - }; - }, [initBackgroundLocalPublisher, backgroundPublisher, destroyBackgroundPublisher]); + }, [initBackgroundLocalPublisher, backgroundPublisher]); // After changing device permissions, reload the page to reflect the device's permission change. useEffect(() => { @@ -140,7 +132,6 @@ const MeetingRoom = (): ReactElement => { screensharingPublisher={screensharingPublisher} screenshareVideoElement={screenshareVideoElement} isRightPanelOpen={rightPanelActiveTab !== 'closed'} - toggleParticipantList={toggleParticipantList} /> diff --git a/frontend/src/pages/WaitingRoom/WaitingRoom.tsx b/frontend/src/pages/WaitingRoom/WaitingRoom.tsx index 4e4b2a2c..28463035 100644 --- a/frontend/src/pages/WaitingRoom/WaitingRoom.tsx +++ b/frontend/src/pages/WaitingRoom/WaitingRoom.tsx @@ -28,11 +28,8 @@ const WaitingRoom = (): ReactElement => { const { initLocalPublisher, publisher, accessStatus, destroyPublisher } = usePreviewPublisherContext(); - const { - initBackgroundLocalPublisher, - publisher: backgroundPublisher, - destroyBackgroundPublisher, - } = useBackgroundPublisherContext(); + const { initBackgroundLocalPublisher, publisher: backgroundPublisher } = + useBackgroundPublisherContext(); const [anchorEl, setAnchorEl] = useState(null); const [openAudioInput, setOpenAudioInput] = useState(false); @@ -58,14 +55,7 @@ const WaitingRoom = (): ReactElement => { if (!backgroundPublisher) { initBackgroundLocalPublisher(); } - - return () => { - // Ensure we destroy the backgroundPublisher and release any media devices. - if (backgroundPublisher) { - destroyBackgroundPublisher(); - } - }; - }, [initBackgroundLocalPublisher, backgroundPublisher, destroyBackgroundPublisher]); + }, [initBackgroundLocalPublisher, backgroundPublisher]); // After changing device permissions, reload the page to reflect the device's permission change. useEffect(() => { diff --git a/frontend/src/types/session.ts b/frontend/src/types/session.ts index 29d95a55..4e73accf 100644 --- a/frontend/src/types/session.ts +++ b/frontend/src/types/session.ts @@ -51,3 +51,5 @@ export type StreamPropertyChangedEvent = { oldValue: boolean | { width: number; height: number }; newValue: boolean | { width: number; height: number }; }; + +export type LayoutMode = 'grid' | 'active-speaker'; diff --git a/frontend/src/utils/getControlButtonTooltip/getControlButtonTooltip.spec.ts b/frontend/src/utils/getControlButtonTooltip/getControlButtonTooltip.spec.ts new file mode 100644 index 00000000..e693da99 --- /dev/null +++ b/frontend/src/utils/getControlButtonTooltip/getControlButtonTooltip.spec.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from 'vitest'; +import { useTranslation } from 'react-i18next'; +import { renderHook } from '@testing-library/react'; +import getControlButtonTooltip from './getControlButtonTooltip'; + +describe('getControlButtonTooltip', () => { + const getTranslationFunction = () => { + const { result } = renderHook(() => useTranslation()); + return result.current.t; + }; + const t = getTranslationFunction(); + + const testCases = [ + { + name: 'audio control is disabled', + options: { + isAudio: true, + allowMicrophoneControl: false, // Microphone control configured to be disabled + allowCameraControl: true, + isAudioEnabled: false, + isVideoEnabled: false, + }, + expectedTranslation: t('devices.audio.disabled'), + }, + { + name: 'audio is disabled', + options: { + isAudio: true, + allowMicrophoneControl: true, + allowCameraControl: true, + isAudioEnabled: false, + isVideoEnabled: false, + }, + // When audio is disabled, the tooltip should prompt to enable it + expectedTranslation: t('devices.audio.enable'), + }, + { + name: 'audio is enabled', + options: { + isAudio: true, + allowMicrophoneControl: true, + allowCameraControl: true, + isAudioEnabled: true, + isVideoEnabled: false, + }, + // When audio is enabled, the tooltip should prompt to disable it + expectedTranslation: t('devices.audio.disable'), + }, + { + name: 'video control is disabled', + options: { + isAudio: false, + allowMicrophoneControl: true, + allowCameraControl: false, // Camera control configured to be disabled + isAudioEnabled: false, + isVideoEnabled: false, + }, + expectedTranslation: t('devices.video.disabled'), + }, + { + name: 'video is disabled', + options: { + isAudio: false, + allowMicrophoneControl: true, + allowCameraControl: true, + isAudioEnabled: false, + isVideoEnabled: false, + }, + // When video is disabled, the tooltip should prompt to enable it + expectedTranslation: t('devices.video.enable'), + }, + { + name: 'video is enabled', + options: { + isAudio: false, + allowMicrophoneControl: true, + allowCameraControl: true, + isAudioEnabled: false, + isVideoEnabled: true, + }, + // When video is enabled, the tooltip should prompt to disable it + expectedTranslation: t('devices.video.disable'), + }, + ]; + + testCases.forEach((testCase) => { + it(`should return correct translation when ${testCase.name}`, () => { + const result = getControlButtonTooltip({ + ...testCase.options, + t, + }); + + expect(result).toBe(testCase.expectedTranslation); + }); + }); +}); diff --git a/frontend/src/utils/getControlButtonTooltip/getControlButtonTooltip.ts b/frontend/src/utils/getControlButtonTooltip/getControlButtonTooltip.ts new file mode 100644 index 00000000..25a14a42 --- /dev/null +++ b/frontend/src/utils/getControlButtonTooltip/getControlButtonTooltip.ts @@ -0,0 +1,37 @@ +export type GetControlButtonTooltipType = { + isAudio: boolean; + allowMicrophoneControl: boolean; + allowCameraControl: boolean; + isAudioEnabled: boolean; + isVideoEnabled: boolean; + t: (key: string) => string; +}; + +/** + * Generates appropriate tooltip text for device control buttons based on device type, + * permission settings, and current device state. + * @param {GetControlButtonTooltipType} options - Configuration object for tooltip generation + * @property {boolean} isAudio - True for microphone controls, false for camera controls + * @property {boolean} allowMicrophoneControl - Whether microphone can be toggled (from config) + * @property {boolean} allowCameraControl - Whether camera can be toggled (from config) + * @property {boolean} isAudioEnabled - Current microphone mute state (true = unmuted) + * @property {boolean} isVideoEnabled - Current camera state (true = on) + * @property {Function} t - Translation function for localized text + * @returns {string} Localized tooltip text appropriate for the current device state + */ +export default (options: GetControlButtonTooltipType): string => { + const { isAudio, allowMicrophoneControl, allowCameraControl, isAudioEnabled, isVideoEnabled, t } = + options; + + if (isAudio) { + if (allowMicrophoneControl) { + return isAudioEnabled ? t('devices.audio.disable') : t('devices.audio.enable'); + } + return t('devices.audio.disabled'); + } + + if (allowCameraControl) { + return isVideoEnabled ? t('devices.video.disable') : t('devices.video.enable'); + } + return t('devices.video.disabled'); +}; diff --git a/frontend/src/utils/getControlButtonTooltip/index.ts b/frontend/src/utils/getControlButtonTooltip/index.ts new file mode 100644 index 00000000..a83fb6be --- /dev/null +++ b/frontend/src/utils/getControlButtonTooltip/index.ts @@ -0,0 +1,3 @@ +import getControlButtonTooltip from './getControlButtonTooltip'; + +export default getControlButtonTooltip; diff --git a/frontend/src/utils/helpers/getLayoutBoxes/getLayoutElements.spec.ts b/frontend/src/utils/helpers/getLayoutBoxes/getLayoutElements.spec.ts index 04195ea5..095b41e5 100644 --- a/frontend/src/utils/helpers/getLayoutBoxes/getLayoutElements.spec.ts +++ b/frontend/src/utils/helpers/getLayoutBoxes/getLayoutElements.spec.ts @@ -1,8 +1,7 @@ import { Dimensions, Publisher } from '@vonage/client-sdk-video'; import { beforeEach, describe, expect, it } from 'vitest'; import getLayoutElementArray from './getLayoutElements'; -import { LayoutMode } from '../../../Context/SessionProvider/session'; -import { SubscriberWrapper } from '../../../types/session'; +import { LayoutMode, SubscriberWrapper } from '../../../types/session'; // Define unique values for width and height so we can identify layout elements const publisherWidth = 101; diff --git a/frontend/src/utils/helpers/getLayoutBoxes/getLayoutElements.ts b/frontend/src/utils/helpers/getLayoutBoxes/getLayoutElements.ts index 6b778cde..d26af37e 100644 --- a/frontend/src/utils/helpers/getLayoutBoxes/getLayoutElements.ts +++ b/frontend/src/utils/helpers/getLayoutBoxes/getLayoutElements.ts @@ -1,8 +1,7 @@ import { Publisher, Subscriber } from '@vonage/client-sdk-video'; import { Box, Element } from 'opentok-layout-js'; import { MaybeElement } from '../../layoutManager'; -import { LayoutMode } from '../../../Context/SessionProvider/session'; -import { SubscriberWrapper } from '../../../types/session'; +import { LayoutMode, SubscriberWrapper } from '../../../types/session'; const isLayoutElement = (element: Element | MaybeElement): element is Element => { return element.width !== undefined && element.height !== undefined; diff --git a/yarn.lock b/yarn.lock index 895a1735..dd828af0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1786,105 +1786,105 @@ estree-walker "^2.0.2" picomatch "^2.3.1" -"@rollup/rollup-android-arm-eabi@4.46.2": - version "4.46.2" - resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.46.2.tgz#292e25953d4988d3bd1af0f5ebbd5ee4d65c90b4" - integrity sha512-Zj3Hl6sN34xJtMv7Anwb5Gu01yujyE/cLBDB2gnHTAHaWS1Z38L7kuSG+oAh0giZMqG060f/YBStXtMH6FvPMA== - -"@rollup/rollup-android-arm64@4.46.2": - version "4.46.2" - resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.46.2.tgz#053b3def3451e6fc1a9078188f22799e868d7c59" - integrity sha512-nTeCWY83kN64oQ5MGz3CgtPx8NSOhC5lWtsjTs+8JAJNLcP3QbLCtDDgUKQc/Ro/frpMq4SHUaHN6AMltcEoLQ== - -"@rollup/rollup-darwin-arm64@4.46.2": - version "4.46.2" - resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.46.2.tgz#98d90445282dec54fd05440305a5e8df79a91ece" - integrity sha512-HV7bW2Fb/F5KPdM/9bApunQh68YVDU8sO8BvcW9OngQVN3HHHkw99wFupuUJfGR9pYLLAjcAOA6iO+evsbBaPQ== - -"@rollup/rollup-darwin-x64@4.46.2": - version "4.46.2" - resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.46.2.tgz#fe05f95a736423af5f9c3a59a70f41ece52a1f20" - integrity sha512-SSj8TlYV5nJixSsm/y3QXfhspSiLYP11zpfwp6G/YDXctf3Xkdnk4woJIF5VQe0of2OjzTt8EsxnJDCdHd2xMA== - -"@rollup/rollup-freebsd-arm64@4.46.2": - version "4.46.2" - resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.46.2.tgz#41e1fbdc1f8c3dc9afb6bc1d6e3fb3104bd81eee" - integrity sha512-ZyrsG4TIT9xnOlLsSSi9w/X29tCbK1yegE49RYm3tu3wF1L/B6LVMqnEWyDB26d9Ecx9zrmXCiPmIabVuLmNSg== - -"@rollup/rollup-freebsd-x64@4.46.2": - version "4.46.2" - resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.46.2.tgz#69131e69cb149d547abb65ef3b38fc746c940e24" - integrity sha512-pCgHFoOECwVCJ5GFq8+gR8SBKnMO+xe5UEqbemxBpCKYQddRQMgomv1104RnLSg7nNvgKy05sLsY51+OVRyiVw== - -"@rollup/rollup-linux-arm-gnueabihf@4.46.2": - version "4.46.2" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.46.2.tgz#977ded91c7cf6fc0d9443bb9c0a064e45a805267" - integrity sha512-EtP8aquZ0xQg0ETFcxUbU71MZlHaw9MChwrQzatiE8U/bvi5uv/oChExXC4mWhjiqK7azGJBqU0tt5H123SzVA== - -"@rollup/rollup-linux-arm-musleabihf@4.46.2": - version "4.46.2" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.46.2.tgz#dc034fc3c0f0eb5c75b6bc3eca3b0b97fd35f49a" - integrity sha512-qO7F7U3u1nfxYRPM8HqFtLd+raev2K137dsV08q/LRKRLEc7RsiDWihUnrINdsWQxPR9jqZ8DIIZ1zJJAm5PjQ== - -"@rollup/rollup-linux-arm64-gnu@4.46.2": - version "4.46.2" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.46.2.tgz#5e92613768d3de3ffcabc965627dd0a59b3e7dfc" - integrity sha512-3dRaqLfcOXYsfvw5xMrxAk9Lb1f395gkoBYzSFcc/scgRFptRXL9DOaDpMiehf9CO8ZDRJW2z45b6fpU5nwjng== - -"@rollup/rollup-linux-arm64-musl@4.46.2": - version "4.46.2" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.46.2.tgz#2a44f88e83d28b646591df6e50aa0a5a931833d8" - integrity sha512-fhHFTutA7SM+IrR6lIfiHskxmpmPTJUXpWIsBXpeEwNgZzZZSg/q4i6FU4J8qOGyJ0TR+wXBwx/L7Ho9z0+uDg== - -"@rollup/rollup-linux-loongarch64-gnu@4.46.2": - version "4.46.2" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.46.2.tgz#bd5897e92db7fbf7dc456f61d90fff96c4651f2e" - integrity sha512-i7wfGFXu8x4+FRqPymzjD+Hyav8l95UIZ773j7J7zRYc3Xsxy2wIn4x+llpunexXe6laaO72iEjeeGyUFmjKeA== - -"@rollup/rollup-linux-ppc64-gnu@4.46.2": - version "4.46.2" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.46.2.tgz#a7065025411c14ad9ec34cc1cd1414900ec2a303" - integrity sha512-B/l0dFcHVUnqcGZWKcWBSV2PF01YUt0Rvlurci5P+neqY/yMKchGU8ullZvIv5e8Y1C6wOn+U03mrDylP5q9Yw== - -"@rollup/rollup-linux-riscv64-gnu@4.46.2": - version "4.46.2" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.46.2.tgz#17f9c0c675e13ef4567cfaa3730752417257ccc3" - integrity sha512-32k4ENb5ygtkMwPMucAb8MtV8olkPT03oiTxJbgkJa7lJ7dZMr0GCFJlyvy+K8iq7F/iuOr41ZdUHaOiqyR3iQ== - -"@rollup/rollup-linux-riscv64-musl@4.46.2": - version "4.46.2" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.46.2.tgz#bc6ed3db2cedc1ba9c0a2183620fe2f792c3bf3f" - integrity sha512-t5B2loThlFEauloaQkZg9gxV05BYeITLvLkWOkRXogP4qHXLkWSbSHKM9S6H1schf/0YGP/qNKtiISlxvfmmZw== - -"@rollup/rollup-linux-s390x-gnu@4.46.2": - version "4.46.2" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.46.2.tgz#440c4f6753274e2928e06d2a25613e5a1cf97b41" - integrity sha512-YKjekwTEKgbB7n17gmODSmJVUIvj8CX7q5442/CK80L8nqOUbMtf8b01QkG3jOqyr1rotrAnW6B/qiHwfcuWQA== - -"@rollup/rollup-linux-x64-gnu@4.46.2": - version "4.46.2" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.46.2.tgz#1e936446f90b2574ea4a83b4842a762cc0a0aed3" - integrity sha512-Jj5a9RUoe5ra+MEyERkDKLwTXVu6s3aACP51nkfnK9wJTraCC8IMe3snOfALkrjTYd2G1ViE1hICj0fZ7ALBPA== - -"@rollup/rollup-linux-x64-musl@4.46.2": - version "4.46.2" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.46.2.tgz#c6f304dfba1d5faf2be5d8b153ccbd8b5d6f1166" - integrity sha512-7kX69DIrBeD7yNp4A5b81izs8BqoZkCIaxQaOpumcJ1S/kmqNFjPhDu1LHeVXv0SexfHQv5cqHsxLOjETuqDuA== - -"@rollup/rollup-win32-arm64-msvc@4.46.2": - version "4.46.2" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.46.2.tgz#b4ad4a79219892aac112ed1c9d1356cad0566ef5" - integrity sha512-wiJWMIpeaak/jsbaq2HMh/rzZxHVW1rU6coyeNNpMwk5isiPjSTx0a4YLSlYDwBH/WBvLz+EtsNqQScZTLJy3g== - -"@rollup/rollup-win32-ia32-msvc@4.46.2": - version "4.46.2" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.46.2.tgz#b1b22eb2a9568048961e4a6f540438b4a762aa62" - integrity sha512-gBgaUDESVzMgWZhcyjfs9QFK16D8K6QZpwAaVNJxYDLHWayOta4ZMjGm/vsAEy3hvlS2GosVFlBlP9/Wb85DqQ== - -"@rollup/rollup-win32-x64-msvc@4.46.2": - version "4.46.2" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.46.2.tgz#87079f137b5fdb75da11508419aa998cc8cc3d8b" - integrity sha512-CvUo2ixeIQGtF6WvuB87XWqPQkoFAFqW+HUo/WzHwuHDvIwZCtjdWXoYCcr06iKGydiqTclC4jU/TNObC/xKZg== +"@rollup/rollup-android-arm-eabi@4.43.0": + version "4.43.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.43.0.tgz#9241b59af721beb7e3587a56c6c245d6c465753d" + integrity sha512-Krjy9awJl6rKbruhQDgivNbD1WuLb8xAclM4IR4cN5pHGAs2oIMMQJEiC3IC/9TZJ+QZkmZhlMO/6MBGxPidpw== + +"@rollup/rollup-android-arm64@4.43.0": + version "4.43.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.43.0.tgz#f70ee53ba991fdd65c277b0716c559736d490a58" + integrity sha512-ss4YJwRt5I63454Rpj+mXCXicakdFmKnUNxr1dLK+5rv5FJgAxnN7s31a5VchRYxCFWdmnDWKd0wbAdTr0J5EA== + +"@rollup/rollup-darwin-arm64@4.43.0": + version "4.43.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.43.0.tgz#9f59000e817cf5760d87515ce899f8b93fe8756a" + integrity sha512-eKoL8ykZ7zz8MjgBenEF2OoTNFAPFz1/lyJ5UmmFSz5jW+7XbH1+MAgCVHy72aG59rbuQLcJeiMrP8qP5d/N0A== + +"@rollup/rollup-darwin-x64@4.43.0": + version "4.43.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.43.0.tgz#c92aebd02725ae1b88bdce40f08f7823e8055c78" + integrity sha512-SYwXJgaBYW33Wi/q4ubN+ldWC4DzQY62S4Ll2dgfr/dbPoF50dlQwEaEHSKrQdSjC6oIe1WgzosoaNoHCdNuMg== + +"@rollup/rollup-freebsd-arm64@4.43.0": + version "4.43.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.43.0.tgz#b128dbe7b353922ddd729a4fc4e408ddcbf338b5" + integrity sha512-SV+U5sSo0yujrjzBF7/YidieK2iF6E7MdF6EbYxNz94lA+R0wKl3SiixGyG/9Klab6uNBIqsN7j4Y/Fya7wAjQ== + +"@rollup/rollup-freebsd-x64@4.43.0": + version "4.43.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.43.0.tgz#88297a0ddfadddd61d7d9b73eb42b3f227301d30" + integrity sha512-J7uCsiV13L/VOeHJBo5SjasKiGxJ0g+nQTrBkAsmQBIdil3KhPnSE9GnRon4ejX1XDdsmK/l30IYLiAaQEO0Cg== + +"@rollup/rollup-linux-arm-gnueabihf@4.43.0": + version "4.43.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.43.0.tgz#a59afc092523ebe43d3899f33da9cdd2ec01fb87" + integrity sha512-gTJ/JnnjCMc15uwB10TTATBEhK9meBIY+gXP4s0sHD1zHOaIh4Dmy1X9wup18IiY9tTNk5gJc4yx9ctj/fjrIw== + +"@rollup/rollup-linux-arm-musleabihf@4.43.0": + version "4.43.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.43.0.tgz#3095c1327b794bd187d03e372e633717fb69b4c0" + integrity sha512-ZJ3gZynL1LDSIvRfz0qXtTNs56n5DI2Mq+WACWZ7yGHFUEirHBRt7fyIk0NsCKhmRhn7WAcjgSkSVVxKlPNFFw== + +"@rollup/rollup-linux-arm64-gnu@4.43.0": + version "4.43.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.43.0.tgz#e43bb77df3a6de85312e991d1e3ad352d1abb00d" + integrity sha512-8FnkipasmOOSSlfucGYEu58U8cxEdhziKjPD2FIa0ONVMxvl/hmONtX/7y4vGjdUhjcTHlKlDhw3H9t98fPvyA== + +"@rollup/rollup-linux-arm64-musl@4.43.0": + version "4.43.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.43.0.tgz#34873a437bcd87618f702dc66f0cbce170aebf9f" + integrity sha512-KPPyAdlcIZ6S9C3S2cndXDkV0Bb1OSMsX0Eelr2Bay4EsF9yi9u9uzc9RniK3mcUGCLhWY9oLr6er80P5DE6XA== + +"@rollup/rollup-linux-loongarch64-gnu@4.43.0": + version "4.43.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.43.0.tgz#224ff524349e365baa56f1f512822548c2d76910" + integrity sha512-HPGDIH0/ZzAZjvtlXj6g+KDQ9ZMHfSP553za7o2Odegb/BEfwJcR0Sw0RLNpQ9nC6Gy8s+3mSS9xjZ0n3rhcYg== + +"@rollup/rollup-linux-powerpc64le-gnu@4.43.0": + version "4.43.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.43.0.tgz#43c3c053b26ace18a1d3dab204596a466c1b0e34" + integrity sha512-gEmwbOws4U4GLAJDhhtSPWPXUzDfMRedT3hFMyRAvM9Mrnj+dJIFIeL7otsv2WF3D7GrV0GIewW0y28dOYWkmw== + +"@rollup/rollup-linux-riscv64-gnu@4.43.0": + version "4.43.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.43.0.tgz#e7df825d71daefa7037605015455aa58be43cd7a" + integrity sha512-XXKvo2e+wFtXZF/9xoWohHg+MuRnvO29TI5Hqe9xwN5uN8NKUYy7tXUG3EZAlfchufNCTHNGjEx7uN78KsBo0g== + +"@rollup/rollup-linux-riscv64-musl@4.43.0": + version "4.43.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.43.0.tgz#d76ad93a7f4c0b2855a024d8d859196acf38acf5" + integrity sha512-ruf3hPWhjw6uDFsOAzmbNIvlXFXlBQ4nk57Sec8E8rUxs/AI4HD6xmiiasOOx/3QxS2f5eQMKTAwk7KHwpzr/Q== + +"@rollup/rollup-linux-s390x-gnu@4.43.0": + version "4.43.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.43.0.tgz#0852608843d05852af3f447bf43bb63d80d62b6a" + integrity sha512-QmNIAqDiEMEvFV15rsSnjoSmO0+eJLoKRD9EAa9rrYNwO/XRCtOGM3A5A0X+wmG+XRrw9Fxdsw+LnyYiZWWcVw== + +"@rollup/rollup-linux-x64-gnu@4.43.0": + version "4.43.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.43.0.tgz#d16a57f86357a4e697142bee244afed59b24e6c5" + integrity sha512-jAHr/S0iiBtFyzjhOkAics/2SrXE092qyqEg96e90L3t9Op8OTzS6+IX0Fy5wCt2+KqeHAkti+eitV0wvblEoQ== + +"@rollup/rollup-linux-x64-musl@4.43.0": + version "4.43.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.43.0.tgz#51cbc8b1eb46ebc0e284725418b6fbf48686e4e2" + integrity sha512-3yATWgdeXyuHtBhrLt98w+5fKurdqvs8B53LaoKD7P7H7FKOONLsBVMNl9ghPQZQuYcceV5CDyPfyfGpMWD9mQ== + +"@rollup/rollup-win32-arm64-msvc@4.43.0": + version "4.43.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.43.0.tgz#d6d84aace2b211119bf0ab1c586e29d01e32aa01" + integrity sha512-wVzXp2qDSCOpcBCT5WRWLmpJRIzv23valvcTwMHEobkjippNf+C3ys/+wf07poPkeNix0paTNemB2XrHr2TnGw== + +"@rollup/rollup-win32-ia32-msvc@4.43.0": + version "4.43.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.43.0.tgz#4af33168de2f65b97a8f36bd1d8d21cea34d3ccb" + integrity sha512-fYCTEyzf8d+7diCw8b+asvWDCLMjsCEA8alvtAutqJOJp/wL5hs1rWSqJ1vkjgW0L2NB4bsYJrpKkiIPRR9dvw== + +"@rollup/rollup-win32-x64-msvc@4.43.0": + version "4.43.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.43.0.tgz#42a88207659e404e8ffa655cae763cbad94906ab" + integrity sha512-SnGhLiE5rlK0ofq8kzuDkM0g7FN1s5VYY+YSMTibP7CqShxCQvqtNxTARS4xX4PFJfHjG0ZQYX9iGzI3FQh5Aw== "@shikijs/core@1.22.2": version "1.22.2"