diff --git a/backend/eslint.config.mjs b/backend/eslint.config.mjs index 83ca90b78..dc95f49ca 100644 --- a/backend/eslint.config.mjs +++ b/backend/eslint.config.mjs @@ -2,12 +2,7 @@ import globals from 'globals'; import baseConfig from '../eslint.config.mjs'; export default [ - { - ...baseConfig, - - files: ['**/*.{ts,tsx,js,jsx}'], - languageOptions: { - globals: globals.node, - }, - }, + ...baseConfig, + { files: ['**/*.{ts,tsx,js,jsx}'], languageOptions: { globals: globals.node } }, + { files: ['**/*.test.{ts,tsx,js,jsx}'], rules: { '@typescript-eslint/await-thenable': 'off' } }, ]; diff --git a/backend/project.json b/backend/project.json index 0538d6e12..1090926e8 100644 --- a/backend/project.json +++ b/backend/project.json @@ -14,10 +14,7 @@ }, "start": { "executor": "nx:run-commands", - "options": { - "cwd": "backend", - "command": "node --import tsx index.ts" - } + "options": { "cwd": "backend", "command": "node --import tsx index.ts" } }, "debug": { "executor": "nx:run-commands", @@ -48,10 +45,14 @@ "inputs": ["default", "^default", { "externalDependencies": ["jest", "ts-jest"] }] }, "ts-check": { + "executor": "nx:run-commands", + "options": { "cwd": "backend", "command": "tsc -p tsconfig.json --pretty true --noEmit" } + }, + "lint": { "executor": "nx:run-commands", "options": { "cwd": "backend", - "command": "tsc --noEmit -p tsconfig.json --pretty true" + "command": "eslint \"**/*.{ts,tsx,js,jsx}\" --report-unused-disable-directives --max-warnings 0" } } }, diff --git a/backend/routes/session.ts b/backend/routes/session.ts index 780b06812..aee392c9b 100644 --- a/backend/routes/session.ts +++ b/backend/routes/session.ts @@ -7,10 +7,7 @@ import createGetOrCreateSession from './getOrCreateSession'; const sessionRouter = Router(); const videoService = createVideoService(); const sessionService = getSessionStorageService(); -const getOrCreateSession = createGetOrCreateSession({ - videoService, - sessionService, -}); +const getOrCreateSession = createGetOrCreateSession({ videoService, sessionService }); sessionRouter.get('/:room', async (req: Request<{ room: string }>, res: Response) => { try { @@ -18,12 +15,7 @@ sessionRouter.get('/:room', async (req: Request<{ room: string }>, res: Response const sessionId = await getOrCreateSession(roomName); const data = videoService.generateToken(sessionId); const captionsId = await sessionService.getCaptionsId(roomName); - res.json({ - sessionId, - token: data.token, - apiKey: data.apiKey, - captionsId, - }); + res.json({ sessionId, token: data.token, apiKey: data.apiKey, captionsId }); } catch (error: unknown) { const message = error instanceof Error ? error.message : error; res.status(500).send({ message }); @@ -36,15 +28,12 @@ sessionRouter.post('/:room/startArchive', async (req: Request<{ room: string }>, const sessionId = await sessionService.getSession(roomName); if (sessionId) { const archive = await videoService.startArchive(roomName, sessionId); - res.json({ - archiveId: archive.id, - status: 200, - }); + res.json({ archiveId: archive.id, status: 200 }); } else { res.status(404).json({ message: 'Room not found' }); } } catch (error: unknown) { - console.log(error); + console.error(error); const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; res.status(500).json({ message: errorMessage }); } @@ -57,10 +46,7 @@ sessionRouter.post( const { archiveId } = req.params; if (archiveId) { const responseArchiveId = await videoService.stopArchive(archiveId); - res.json({ - archiveId: responseArchiveId, - status: 200, - }); + res.json({ archiveId: responseArchiveId, status: 200 }); } } catch (error: unknown) { res.status(500).send({ message: (error as Error).message ?? error }); @@ -74,10 +60,7 @@ sessionRouter.get('/:room/archives', async (req: Request<{ room: string }>, res: const sessionId = await sessionService.getSession(roomName); if (sessionId) { const archives = await videoService.listArchives(sessionId); - res.json({ - archives, - status: 200, - }); + res.json({ archives, status: 200 }); } else { res.status(404).json({ message: 'Room not found' }); } @@ -109,10 +92,7 @@ sessionRouter.post( } else { // the captions were already enabled for this room const captionsId = await sessionService.getCaptionsId(roomName); - res.json({ - captionsId, - status: 200, - }); + res.json({ captionsId, status: 200 }); } } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; @@ -148,15 +128,9 @@ sessionRouter.post( if (captionsUserCount === 0) { const disableResponse = await videoService.disableCaptions(captionsId); await sessionService.setCaptionsId(roomName, ''); - res.json({ - disableResponse, - status: 200, - }); + res.json({ disableResponse, status: 200 }); } else { - res.json({ - disableResponse: 'Captions are still active for other users', - status: 200, - }); + res.json({ disableResponse: 'Captions are still active for other users', status: 200 }); } } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; diff --git a/backend/server.ts b/backend/server.ts index 5726f2722..b4ca6f51b 100644 --- a/backend/server.ts +++ b/backend/server.ts @@ -34,7 +34,7 @@ app.get('/*', (_req: Request, res: Response) => { const startServer: () => Promise = () => { return new Promise((res) => { const server: Server = app.listen(port, () => { - console.log('Server listening on port', port); + console.info('Server listening on port', port); res(server); }); }); diff --git a/backend/services/jiraFeedbackService.ts b/backend/services/jiraFeedbackService.ts index 0c9881f51..376c923c8 100644 --- a/backend/services/jiraFeedbackService.ts +++ b/backend/services/jiraFeedbackService.ts @@ -33,29 +33,18 @@ class JiraFeedbackService implements FeedbackService { async reportIssue(data: FeedbackData): Promise { const feedbackIssueData = { fields: { - project: { - key: this.jiraKey, - }, + project: { key: this.jiraKey }, summary: data.title, description: `Reported by: ${data.name}\n\n Issue description:\n${data.issue}`, - issuetype: { - name: 'Bug', - }, - components: [ - { - id: this.getComponentIdByOrigin(data.origin), - }, - ], + issuetype: { name: 'Bug' }, + components: [{ id: this.getComponentIdByOrigin(data.origin) }], [this.jiraEpicLink]: this.jiraEpicUrl, }, }; try { - const response = await axios.post(this.jiraApiUrl, feedbackIssueData, { - headers: { - 'Content-Type': 'application/json', - Authorization: `Basic ${this.jiraToken}`, - }, + const response = await axios.post<{ key: string }>(this.jiraApiUrl, feedbackIssueData, { + headers: { 'Content-Type': 'application/json', Authorization: `Basic ${this.jiraToken}` }, }); const ticketData: ReportIssueReturn = { @@ -68,7 +57,7 @@ class JiraFeedbackService implements FeedbackService { } return await this.addAttachment(data.attachment, ticketData, response.data.key); } catch (error) { - console.log('Response Error:', error); + console.error('Response Error:', error); return null; } } @@ -80,10 +69,7 @@ class JiraFeedbackService implements FeedbackService { ): Promise { const fileBuffer = Buffer.from(attachment, 'base64'); const formData = new FormData(); - formData.append('file', fileBuffer, { - filename: 'screenshot.png', - contentType: 'image/png', - }); + formData.append('file', fileBuffer, { filename: 'screenshot.png', contentType: 'image/png' }); await axios.post(`${this.jiraApiUrl}${key}/attachments`, formData, { headers: { @@ -94,10 +80,7 @@ class JiraFeedbackService implements FeedbackService { }, }); - return { - ...ticketData, - screenshotIncluded: true, - }; + return { ...ticketData, screenshotIncluded: true }; } private getComponentIdByOrigin(origin: FeedbackOrigin): string { diff --git a/backend/storage/inMemorySessionStorage.ts b/backend/storage/inMemorySessionStorage.ts index 333f22350..d8d7b9af2 100644 --- a/backend/storage/inMemorySessionStorage.ts +++ b/backend/storage/inMemorySessionStorage.ts @@ -1,3 +1,5 @@ +// [TODO]: Fix require-await linting issue +/* eslint-disable @typescript-eslint/require-await */ import { SessionStorage } from './sessionStorage'; interface SessionData { @@ -14,11 +16,7 @@ class InMemorySessionStorage implements SessionStorage { } async setSession(roomName: string, sessionId: string): Promise { - this.sessions[roomName] = { - sessionId, - captionsId: null, - captionsUserCount: 0, - }; + this.sessions[roomName] = { sessionId, captionsId: null, captionsUserCount: 0 }; } async setCaptionsId(roomName: string, captionsId: string): Promise { diff --git a/backend/storage/tests/inMemorySessionStorage.test.ts b/backend/storage/tests/inMemorySessionStorage.test.ts index 9b55efb6f..41a0abb13 100644 --- a/backend/storage/tests/inMemorySessionStorage.test.ts +++ b/backend/storage/tests/inMemorySessionStorage.test.ts @@ -89,7 +89,6 @@ describe('InMemorySessionStorage', () => { await storage.setSession(room, 'session123'); await storage.incrementCaptionsUserCount(room); for (let i = 0; i < 5; i++) { - // eslint-disable-next-line no-await-in-loop const count = await storage.decrementCaptionsUserCount(room); expect(count).toBe(0); } diff --git a/backend/storage/vcrSessionStorage.ts b/backend/storage/vcrSessionStorage.ts index ac58befd8..6afbdf999 100644 --- a/backend/storage/vcrSessionStorage.ts +++ b/backend/storage/vcrSessionStorage.ts @@ -46,7 +46,7 @@ class VcrSessionStorage implements SessionStorage { async incrementCaptionsUserCount(roomName: string): Promise { const key = `captionsUserCount:${roomName}`; - const currentCaptionsUsersCount = (await this.dbState.get(key)) as number; + const currentCaptionsUsersCount = await this.dbState.get(key); const newCaptionsUsersCount = currentCaptionsUsersCount ? currentCaptionsUsersCount + 1 : 1; await this.dbState.set(key, newCaptionsUsersCount); await this.setKeyExpiry(key); @@ -56,7 +56,7 @@ class VcrSessionStorage implements SessionStorage { async decrementCaptionsUserCount(roomName: string): Promise { const key = `captionsUserCount:${roomName}`; - const currentCaptionsUsersCount = (await this.dbState.get(key)) as number; + const currentCaptionsUsersCount = await this.dbState.get(key); const newCaptionsUsersCount = currentCaptionsUsersCount ? currentCaptionsUsersCount - 1 : 0; if (newCaptionsUsersCount < 0) { await this.dbState.delete(key); diff --git a/backend/tests/apple-app-site-association.test.ts b/backend/tests/apple-app-site-association.test.ts index 7f9253e00..e5eb8206c 100644 --- a/backend/tests/apple-app-site-association.test.ts +++ b/backend/tests/apple-app-site-association.test.ts @@ -28,7 +28,9 @@ describe('GET /.well-known/apple-app-site-association', () => { it('returns valid JSON content', async () => { const res = await request(server).get('/.well-known/apple-app-site-association'); - expect(() => JSON.parse(res.text)).not.toThrow(); + expect(() => { + JSON.parse(res.text); + }).not.toThrow(); }); it('returns the correct structure for asset links', async () => { @@ -42,13 +44,8 @@ describe('GET /.well-known/apple-app-site-association', () => { expect.objectContaining({ appIDs: expect.arrayContaining(['PR6C39UQ38.com.vonage.VERA']), components: expect.arrayContaining([ - expect.objectContaining({ - '/': '/waiting-room/*', - }), - expect.objectContaining({ - '/': '/room/*', - comment: 'Matches any room URL', - }), + expect.objectContaining({ '/': '/waiting-room/*' }), + expect.objectContaining({ '/': '/room/*', comment: 'Matches any room URL' }), ]), }), ]), diff --git a/backend/tests/assetlinks.test.ts b/backend/tests/assetlinks.test.ts index bd888f2eb..faf94a7e2 100644 --- a/backend/tests/assetlinks.test.ts +++ b/backend/tests/assetlinks.test.ts @@ -28,7 +28,9 @@ describe('GET /.well-known/assetlinks.json', () => { it('returns valid JSON content', async () => { const res = await request(server).get('/.well-known/assetlinks.json'); - expect(() => JSON.parse(res.text)).not.toThrow(); + expect(() => { + JSON.parse(res.text); + }).not.toThrow(); }); it('returns the correct structure for asset links', async () => { diff --git a/backend/types/opentok-jwt-d.ts b/backend/types/opentok-jwt-d.ts index bb1c7b3c2..a418a9b8e 100644 --- a/backend/types/opentok-jwt-d.ts +++ b/backend/types/opentok-jwt-d.ts @@ -1,4 +1,3 @@ declare module 'opentok-jwt' { - // eslint-disable-next-line import/prefer-default-export export function projectToken(apiKey: string, apiSecret: string, expire?: number): string; } diff --git a/backend/videoService/tests/opentokVideoService.test.ts b/backend/videoService/tests/opentokVideoService.test.ts index 75fdb0579..87f8943c1 100644 --- a/backend/videoService/tests/opentokVideoService.test.ts +++ b/backend/videoService/tests/opentokVideoService.test.ts @@ -15,28 +15,16 @@ await jest.unstable_mockModule('opentok', () => ({ callback(null, { sessionId: mockSessionId }); } ), - generateToken: jest.fn<() => { token: string; apiKey: string }>().mockReturnValue({ - token: mockToken, - apiKey: mockApiKey, - }), + generateToken: jest + .fn<() => { token: string; apiKey: string }>() + .mockReturnValue({ token: mockToken, apiKey: mockApiKey }), startArchive: jest.fn( ( _sessionId: string, _options: unknown, - callback: ( - err: unknown, - session: { - archive: { - id: string; - }; - } - ) => void + callback: (err: unknown, session: { archive: { id: string } }) => void ) => { - callback(null, { - archive: { - id: mockArchiveId, - }, - }); + callback(null, { archive: { id: mockArchiveId } }); } ), stopArchive: jest.fn( @@ -55,9 +43,9 @@ await jest.unstable_mockModule('opentok', () => ({ await jest.unstable_mockModule('axios', () => ({ default: { - post: jest.fn<() => Promise<{ data: { captionsId: string } }>>().mockResolvedValue({ - data: { captionsId: mockCaptionId }, - }), + post: jest + .fn<() => Promise<{ data: { captionsId: string } }>>() + .mockResolvedValue({ data: { captionsId: mockCaptionId } }), }, })); @@ -81,19 +69,12 @@ describe('OpentokVideoService', () => { it('generates a token', () => { const result = opentokVideoService.generateToken(mockSessionId); - expect(result.token).toEqual({ - apiKey: mockApiKey, - token: mockToken, - }); + expect(result.token).toEqual({ apiKey: mockApiKey, token: mockToken }); }); it('starts an archive', async () => { const response = await opentokVideoService.startArchive(mockRoomName, mockSessionId); - expect(response).toMatchObject({ - archive: { - id: mockArchiveId, - }, - }); + expect(response).toMatchObject({ archive: { id: mockArchiveId } }); }); it('stops an archive', async () => { diff --git a/backend/videoService/tests/vonageVideoService.test.ts b/backend/videoService/tests/vonageVideoService.test.ts index 9b7ecb784..879f991c9 100644 --- a/backend/videoService/tests/vonageVideoService.test.ts +++ b/backend/videoService/tests/vonageVideoService.test.ts @@ -12,38 +12,31 @@ const mockPrivateKey = 'mockPrivateKey'; await jest.unstable_mockModule('@vonage/video', () => { return { - Video: jest.fn().mockImplementation(() => ({ - createSession: jest.fn<() => Promise<{ sessionId: string }>>().mockResolvedValue({ - sessionId: mockSessionId, - }), - generateClientToken: jest.fn<() => { token: string; apiKey: string }>().mockReturnValue({ - token: mockToken, - apiKey: mockApplicationId, - }), - startArchive: jest.fn<() => Promise<{ id: string }>>().mockResolvedValue({ - id: mockArchiveId, - }), - stopArchive: jest.fn<() => Promise<{ status: number }>>().mockResolvedValue({ - status: 200, - }), - enableCaptions: jest.fn<() => Promise<{ captionsId: string }>>().mockResolvedValue({ - captionsId: mockCaptionId, - }), - disableCaptions: jest.fn<() => Promise<{ status: number }>>().mockResolvedValue({ - status: 200, - }), + Video: jest.fn(() => ({ + createSession: jest + .fn<() => Promise<{ sessionId: string }>>() + .mockResolvedValue({ sessionId: mockSessionId }), + generateClientToken: jest + .fn<() => { token: string; apiKey: string }>() + .mockReturnValue({ token: mockToken, apiKey: mockApplicationId }), + startArchive: jest + .fn<() => Promise<{ id: string }>>() + .mockResolvedValue({ id: mockArchiveId }), + stopArchive: jest.fn<() => Promise<{ status: number }>>().mockResolvedValue({ status: 200 }), + enableCaptions: jest + .fn<() => Promise<{ captionsId: string }>>() + .mockResolvedValue({ captionsId: mockCaptionId }), + disableCaptions: jest + .fn<() => Promise<{ status: number }>>() + .mockResolvedValue({ status: 200 }), })), LayoutType: { BEST_FIT: 'bestFit', HORIZONTAL_PRESENTATION: 'horizontalPresentation', CUSTOM: 'custom', }, - MediaMode: { - ROUTED: 'routed', - }, - Resolution: { - FHD_LANDSCAPE: '1920x1080', - }, + MediaMode: { ROUTED: 'routed' }, + Resolution: { FHD_LANDSCAPE: '1920x1080' }, }; }); @@ -67,10 +60,7 @@ describe('VonageVideoService', () => { it('generates a token', () => { const result = vonageVideoService.generateToken(mockSessionId); - expect(result.token).toEqual({ - apiKey: mockApplicationId, - token: mockToken, - }); + expect(result.token).toEqual({ apiKey: mockApplicationId, token: mockToken }); }); it('starts an archive', async () => { diff --git a/backend/videoService/vonageVideoService.ts b/backend/videoService/vonageVideoService.ts index f27461e9e..29e058da5 100644 --- a/backend/videoService/vonageVideoService.ts +++ b/backend/videoService/vonageVideoService.ts @@ -1,4 +1,3 @@ -/* eslint-disable no-underscore-dangle */ import { Auth } from '@vonage/auth'; import { LayoutType, diff --git a/customWordList.mjs b/customWordList.mjs index 2a1054758..ee4d99806 100644 --- a/customWordList.mjs +++ b/customWordList.mjs @@ -10,6 +10,7 @@ const customWordList = [ 'jiraiOSComponentId', 'supportedLngs', 'applinks', + 'PWDEBUG', ]; export default customWordList; diff --git a/eslint.config.mjs b/eslint.config.mjs index 3eb179b08..ce845e66e 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -55,6 +55,8 @@ export default [ 'eslint.config.mjs', 'scripts/licenseCheck.js', 'frontend/tailwind.config.js', + 'backend/jest.config.js', + 'backend/jest/setEnvVars.js', 'integration-tests/globalSetup.js', // add more config files here if needed, e.g. // 'frontend/tailwind.config.*', @@ -106,6 +108,7 @@ export default [ // General style 'prettier/prettier': 'error', + 'no-console': ['error', { allow: ['warn', 'error', 'info'] }], // TypeScript // Removed duplicates already enforced by TypeScript: @@ -234,6 +237,25 @@ export default [ */ 'react-hooks/refs': 'off', + /** + * This rule is too restrictive in practice, + * + * When working with stable values mutability is common and safe + * ```ts + * const renderCountRef = useRef(0); + * renderCountRef.current += 1; + * ``` + */ + 'react-hooks/immutability': 'off', + + /** + * React `use` is not context-aware, which means that you can use it outside Suspense boundaries. + * This could make the application crash silently at runtime. To prevent this, we will use Suspense$ and use$ instead. + * + * Suspense$ provides context, and use$ will throw if used outside Suspense$ boundaries. + */ + 'react/jsx-pascal-case': ['error', { ignore: ['Suspense$', 'use$'] }], + // Accessibility [todo]: review if we can enable them, otherwise why using jsx-a11y? 'jsx-a11y/media-has-caption': 'off', 'jsx-a11y/no-static-element-interactions': 'off', @@ -258,6 +280,16 @@ export default [ rules: { // unit test usually need to mock before importing to make the mocking work 'import/first': 'off', + '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!' }, + ], }, }, ]; diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index d1eec06c6..28861f9fb 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -23,10 +23,17 @@ export default [ whitelist: [ 'bg-primary-dark', 'text-darkGray', + 'bg-darkGray-100', + 'bg-darkGray-55', + 'bg-notVeryGray-100', + 'bg-notVeryGray-55', + // the following are used in playwright testing 'publisher', 'subscriber', 'screen-subscriber', - 'bg-notVeryGray-100', + + // custom tailwind classes defined in our tailwind.config.ts + 'animate-fade-in', ], }, ], diff --git a/frontend/project.json b/frontend/project.json index 3b28a1ed3..55fb2bb3b 100644 --- a/frontend/project.json +++ b/frontend/project.json @@ -8,10 +8,7 @@ "build": { "executor": "nx:run-commands", "outputs": ["{projectRoot}/dist"], - "options": { - "cwd": "frontend", - "command": "vite build && nx run frontend:cp-build" - } + "options": { "cwd": "frontend", "command": "vite build && nx run frontend:cp-build" } }, "dev": { "executor": "nx:run-commands", @@ -22,17 +19,11 @@ }, "serve": { "executor": "nx:run-commands", - "options": { - "cwd": "frontend", - "command": "vite --host" - } + "options": { "cwd": "frontend", "command": "vite --host" } }, "preview": { "executor": "nx:run-commands", - "options": { - "cwd": "frontend", - "command": "vite preview" - } + "options": { "cwd": "frontend", "command": "vite preview" } }, "lint": { "executor": "nx:run-commands", @@ -43,38 +34,23 @@ }, "test": { "executor": "nx:run-commands", - "options": { - "cwd": "frontend", - "command": "vitest run --coverage" - } + "options": { "cwd": "frontend", "command": "vitest run --coverage" } }, "test:watch": { "executor": "nx:run-commands", - "options": { - "cwd": "frontend", - "command": "vitest" - } + "options": { "cwd": "frontend", "command": "vitest" } }, "docs": { "executor": "nx:run-commands", - "options": { - "cwd": "frontend", - "command": "typedoc" - } + "options": { "cwd": "frontend", "command": "typedoc" } }, "docs:watch": { "executor": "nx:run-commands", - "options": { - "cwd": "frontend", - "command": "typedoc --watch" - } + "options": { "cwd": "frontend", "command": "typedoc --watch" } }, "ts-check": { "executor": "nx:run-commands", - "options": { - "cwd": "frontend", - "command": "tsc -p tsconfig.json --pretty true --noEmit" - } + "options": { "cwd": "frontend", "command": "tsc -p tsconfig.json --pretty true --noEmit" } }, "ts-check:watch": { "executor": "nx:run-commands", diff --git a/frontend/src/App.spec.tsx b/frontend/src/App.spec.tsx index d87edc220..1288c80e5 100644 --- a/frontend/src/App.spec.tsx +++ b/frontend/src/App.spec.tsx @@ -14,22 +14,24 @@ vi.mock('./components/RedirectToWaitingRoom', () => ({ default: ({ children }: PropsWithChildren) => children, })); +const appConfig = { isAppConfigLoaded: true }; + describe('App routing', () => { it('renders LandingPage on unknown route', () => { window.history.pushState({}, '', '/unknown'); - render(); + render(); expect(screen.getByText(/Landing Page/i)).toBeInTheDocument(); }); it('renders GoodBye page on /goodbye', () => { window.history.pushState({}, '', '/goodbye'); - render(); + render(); expect(screen.getByText(/GoodBye Page/i)).toBeInTheDocument(); }); it('renders UnsupportedBrowserPage on /unsupported-browser', () => { window.history.pushState({}, '', '/unsupported-browser'); - render(); + render(); expect(screen.getByText(/Unsupported Browser/i)).toBeInTheDocument(); }); }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6475324b0..316478368 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,52 +2,65 @@ import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'; import './css/App.css'; import './css/index.css'; import { CssBaseline } from '@mui/material'; -import { CustomThemeProvider } from '@Context/Theme/themeContext'; -import Room from './pages/MeetingRoom/index'; import GoodBye from './pages/GoodBye/index'; import WaitingRoom from './pages/WaitingRoom'; -import SessionProvider from './Context/SessionProvider/session'; import { PreviewPublisherProvider } from './Context/PreviewPublisherProvider'; import LandingPage from './pages/LandingPage'; import { PublisherProvider } from './Context/PublisherProvider'; import RedirectToWaitingRoom from './components/RedirectToWaitingRoom'; import UnsupportedBrowserPage from './pages/UnsupportedBrowserPage'; import RoomContext from './Context/RoomContext'; +import AppContextProvider from './AppContextProvider'; +import { DeepPartial } from './types'; +import { AppConfig } from '@Context/AppConfig'; +import RedirectToUnsupportedBrowserPage from '@components/RedirectToUnsupportedBrowserPage'; +import Suspense$ from '@Context/Suspense$'; +import WaitingRoomSkeleton from '@pages/WaitingRoom/WaitingRoom.skeleton'; +import MeetingRoomSkeleton from '@pages/MeetingRoom/MeetingRoom.skeleton'; +import MeetingRoom from '@pages/MeetingRoom'; -const App = () => { +const App = ({ appConfigValue }: { appConfigValue?: DeepPartial }) => { return ( - + - }> + }> - - + }> + + + + + + } /> + - - - - - - + + }> + + + + + + + } /> + } /> } /> } /> - + ); }; diff --git a/frontend/src/AppContextProvider.tsx b/frontend/src/AppContextProvider.tsx new file mode 100644 index 000000000..769f7b093 --- /dev/null +++ b/frontend/src/AppContextProvider.tsx @@ -0,0 +1,35 @@ +import { AppConfigApi } from '@Context/AppConfig/actions/loadAppConfig'; +import React, { type PropsWithChildren } from 'react'; +import appConfig, { AppConfig } from '@Context/AppConfig'; +import UserProvider from '@Context/user'; +import mergeAppConfigs from '@Context/AppConfig/helpers/mergeAppConfigs'; +import { DeepPartial } from './types'; +import { CustomThemeProvider } from '@Context/Theme/themeContext'; + +type AppContextProviderProps = PropsWithChildren<{ appConfigValue?: DeepPartial }>; + +const AppContextProvider: React.FC = ({ children, appConfigValue }) => { + return ( + + + {children} + + + ); +}; + +/** + * 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 AppContextProvider; diff --git a/frontend/src/Context/AppConfig/AppConfigContext.spec.tsx b/frontend/src/Context/AppConfig/AppConfigContext.spec.tsx index 9eca8c6af..b38ad0c18 100644 --- a/frontend/src/Context/AppConfig/AppConfigContext.spec.tsx +++ b/frontend/src/Context/AppConfig/AppConfigContext.spec.tsx @@ -30,9 +30,7 @@ describe('AppConfigContext', () => { allowAudioOnJoin: false, allowMicrophoneControl: false, }, - waitingRoomSettings: { - allowDeviceSelection: false, - }, + waitingRoomSettings: { allowDeviceSelection: false }, meetingRoomSettings: { allowArchiving: false, allowCaptions: false, @@ -47,9 +45,7 @@ describe('AppConfigContext', () => { vi.spyOn(global, 'fetch').mockResolvedValue({ json: () => mockConfig, - headers: { - get: () => 'application/json', - }, + headers: { get: () => 'application/json' }, } as unknown as Response); const { result } = renderHook(() => appConfigStore.use()); @@ -61,14 +57,11 @@ describe('AppConfigContext', () => { [appConfig, { loadAppConfig }] = result.current; - expect(appConfig).toEqual({ - ...mockConfig, - isAppConfigLoaded: true, - }); + expect(appConfig).toEqual({ ...mockConfig, isAppConfigLoaded: true }); }); it('falls back to defaultConfig if fetch fails', async () => { - expect.assertions(4); + expect.assertions(3); const mockFetchError = new Error('mocking a failure to fetch'); @@ -78,7 +71,8 @@ describe('AppConfigContext', () => { let [appConfig, { loadAppConfig }] = result.current; expect(appConfig.isAppConfigLoaded).toBe(false); - expect(loadAppConfig()).rejects.toThrow('mocking a failure to fetch'); + + await loadAppConfig(); // test will fail without the await act // eslint-disable-next-line @typescript-eslint/await-thenable @@ -90,22 +84,17 @@ describe('AppConfigContext', () => { expect(appConfig.isAppConfigLoaded).toBe(true); - expect(appConfig).toEqual({ - ...defaultAppConfig, - isAppConfigLoaded: true, - }); + expect(appConfig).toEqual({ ...defaultAppConfig, isAppConfigLoaded: true }); }); it('falls back to defaultConfig if no config.json is found', async () => { - expect.assertions(4); + expect.assertions(3); vi.spyOn(global, 'fetch').mockResolvedValue({ ok: false, status: 404, statusText: 'Not Found', - headers: { - get: () => 'text/html', - }, + headers: { get: () => 'text/html' }, } as unknown as Response); const { result, rerender } = renderHook(() => appConfigStore.use()); @@ -113,7 +102,7 @@ describe('AppConfigContext', () => { expect(appConfig).toEqual(defaultAppConfig); - expect(loadAppConfig()).rejects.toThrow('No valid JSON found, using default config'); + await loadAppConfig(); // test will fail without the await act // eslint-disable-next-line @typescript-eslint/await-thenable @@ -125,10 +114,7 @@ describe('AppConfigContext', () => { expect(appConfig.isAppConfigLoaded).toBe(true); - expect(appConfig).toEqual({ - ...defaultAppConfig, - isAppConfigLoaded: true, - }); + expect(appConfig).toEqual({ ...defaultAppConfig, isAppConfigLoaded: true }); }); }); diff --git a/frontend/src/Context/AppConfig/actions/loadAppConfig.ts b/frontend/src/Context/AppConfig/actions/loadAppConfig.ts index eeaa4b01a..d49abdb4f 100644 --- a/frontend/src/Context/AppConfig/actions/loadAppConfig.ts +++ b/frontend/src/Context/AppConfig/actions/loadAppConfig.ts @@ -1,4 +1,6 @@ +import tryCatch from '@utils/tryCatch'; import type { AppConfig } from '../AppConfigContext.types'; +import env from '../../../env'; export type AppConfigApi = import('../AppConfigContext').AppConfigApi; @@ -10,24 +12,38 @@ export type AppConfigApi = import('../AppConfigContext').AppConfigApi; */ function loadAppConfig(this: AppConfigApi['actions']) { return async (_: AppConfigApi) => { - try { - const response = await fetch('/config.json', { - cache: 'no-cache', - }); + const fallbackConfig: Partial = {}; + + const { result: config, error } = await tryCatch(async () => { + // Skip fetching config in CI environments + if (env.VITE_AVOID_FETCHING_APP_CONFIG) return {}; + + const response = await fetch('/config.json', { cache: 'no-cache' }); const contentType = response.headers.get('content-type'); + + // avoid throwing on CI environments where no config.json is present if (!contentType?.includes('application/json')) { throw new Error('No valid JSON found, using default config'); } + // [TODO]: Validate schema of json const json: Partial = await response.json(); - this.updateAppConfig(json); - } finally { - this.updateAppConfig({ - isAppConfigLoaded: true, - }); + return json; + }, fallbackConfig); + + if (error && env.MODE === 'development') { + console.error( + [ + 'There was an error loading config.json', + 'Please make sure to provide a valid config.json file in the public folder.', + ].join('\n'), + error + ); } + + this.updateAppConfig({ ...config, isAppConfigLoaded: true }); }; } diff --git a/frontend/src/Context/AppConfig/helpers/mergeAppConfigs.ts b/frontend/src/Context/AppConfig/helpers/mergeAppConfigs.ts index e59ca1d75..48dd28195 100644 --- a/frontend/src/Context/AppConfig/helpers/mergeAppConfigs.ts +++ b/frontend/src/Context/AppConfig/helpers/mergeAppConfigs.ts @@ -7,7 +7,7 @@ import defaultAppConfig from './defaultAppConfig'; * @param {DeepPartial} updates - Partial updates to apply to the app config. * @returns {AppConfig} The merged app config. */ -function mergeAppConfigs(updates: DeepPartial): AppConfig; +function mergeAppConfigs(updates?: DeepPartial): AppConfig; /** * Merge two app configs. @@ -19,12 +19,7 @@ function mergeAppConfigs(updates: DeepPartial): AppConfig; function mergeAppConfigs(args: { previous: AppConfig; updates: DeepPartial }): AppConfig; function mergeAppConfigs( - args: - | { - previous: AppConfig; - updates: DeepPartial; - } - | DeepPartial + args: { previous: AppConfig; updates: DeepPartial } | DeepPartial = {} ): AppConfig { const shouldUseDefault = !('previous' in args && 'updates' in args); const previous = shouldUseDefault ? defaultAppConfig : args.previous; @@ -33,22 +28,10 @@ function mergeAppConfigs( return { ...previous, ...updates, - videoSettings: { - ...previous.videoSettings, - ...updates.videoSettings, - }, - audioSettings: { - ...previous.audioSettings, - ...updates.audioSettings, - }, - waitingRoomSettings: { - ...previous.waitingRoomSettings, - ...updates.waitingRoomSettings, - }, - meetingRoomSettings: { - ...previous.meetingRoomSettings, - ...updates.meetingRoomSettings, - }, + videoSettings: { ...previous.videoSettings, ...updates.videoSettings }, + audioSettings: { ...previous.audioSettings, ...updates.audioSettings }, + waitingRoomSettings: { ...previous.waitingRoomSettings, ...updates.waitingRoomSettings }, + meetingRoomSettings: { ...previous.meetingRoomSettings, ...updates.meetingRoomSettings }, }; } diff --git a/frontend/src/Context/AppConfig/hooks/useIsBackgroundEffectsAllowed.ts b/frontend/src/Context/AppConfig/hooks/useIsBackgroundEffectsAllowed.ts new file mode 100644 index 000000000..114b81976 --- /dev/null +++ b/frontend/src/Context/AppConfig/hooks/useIsBackgroundEffectsAllowed.ts @@ -0,0 +1,8 @@ +import appConfig from '../AppConfigContext'; + +const useIsBackgroundEffectsAllowed = appConfig.use.createSelectorHook( + ({ isAppConfigLoaded, videoSettings }) => + isAppConfigLoaded && videoSettings.allowBackgroundEffects +); + +export default useIsBackgroundEffectsAllowed; diff --git a/frontend/src/Context/AppConfig/hooks/useSuspenseUntilAppConfigReady.ts b/frontend/src/Context/AppConfig/hooks/useSuspenseUntilAppConfigReady.ts new file mode 100644 index 000000000..c36bb58e8 --- /dev/null +++ b/frontend/src/Context/AppConfig/hooks/useSuspenseUntilAppConfigReady.ts @@ -0,0 +1,38 @@ +import useSuspenseMemo from '@hooks/useSuspenseMemo'; +import { useEffect } from 'react'; +import defer from '@utils/defer'; +import appConfig from '../AppConfigContext'; + +/** + * Suspends the component or hook until the app configuration is fully loaded. + */ +const useSuspenseUntilAppConfigReady = (): void => { + const observable = appConfig.use.observable(({ isAppConfigLoaded }) => isAppConfigLoaded); + + useSuspenseMemo(() => { + const isAppConfigLoaded = observable.getState(); + + if (isAppConfigLoaded) { + return null; + } + + const deferred = defer(); + + const unsubscribe = observable.subscribe((isAppConfigLoaded) => { + if (isAppConfigLoaded) { + unsubscribe(); + deferred.resolve(); + } + }); + + return deferred.promise; + }, [observable]); + + useEffect(() => { + return () => { + observable.dispose(); + }; + }, [observable]); +}; + +export default useSuspenseUntilAppConfigReady; diff --git a/frontend/src/Context/BackgroundPublisherProvider/index.tsx b/frontend/src/Context/BackgroundPublisherProvider/index.tsx index c38dfa511..cf1323292 100644 --- a/frontend/src/Context/BackgroundPublisherProvider/index.tsx +++ b/frontend/src/Context/BackgroundPublisherProvider/index.tsx @@ -1,4 +1,4 @@ -import { ReactElement, ReactNode, createContext, useMemo } from 'react'; +import { ReactElement, ReactNode, createContext } from 'react'; import useBackgroundPublisher from './useBackgroundPublisher'; export type BackgroundPublisherContextType = ReturnType; @@ -22,9 +22,9 @@ export const BackgroundPublisherProvider = ({ children: ReactNode; }): ReactElement => { const backgroundPublisherContext = useBackgroundPublisher(); - const value = useMemo(() => backgroundPublisherContext, [backgroundPublisherContext]); + return ( - + {children} ); diff --git a/frontend/src/Context/BackgroundPublisherProvider/useBackgroundPublisher/useBackgroundPublisher.spec.tsx b/frontend/src/Context/BackgroundPublisherProvider/useBackgroundPublisher/useBackgroundPublisher.spec.tsx index 9d2a430d1..d49d70728 100644 --- a/frontend/src/Context/BackgroundPublisherProvider/useBackgroundPublisher/useBackgroundPublisher.spec.tsx +++ b/frontend/src/Context/BackgroundPublisherProvider/useBackgroundPublisher/useBackgroundPublisher.spec.tsx @@ -7,16 +7,16 @@ 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 composeProviders from '@utils/composeProviders'; +import Suspense$ from '@Context/Suspense$'; +import { makeAppConfigProviderWrapper } from '@test/providers'; import useBackgroundPublisher from './useBackgroundPublisher'; -import usePublisherOptions from '../../PublisherProvider/usePublisherOptions'; +import { UserContextType } from '../../user'; 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 defaultSettings = { publishAudio: false, @@ -26,10 +26,7 @@ const defaultSettings = { publishCaptions: false, }; const mockUserContextWithDefaultSettings = { - user: { - defaultSettings, - issues: { reconnections: 0, audioFallbacks: 0 }, - }, + user: { defaultSettings, issues: { reconnections: 0, audioFallbacks: 0 } }, setUser: vi.fn(), } as UserContextType; @@ -50,26 +47,18 @@ describe('useBackgroundPublisher', () => { vi.mocked(useUserContext).mockImplementation(() => mockUserContextWithDefaultSettings); (initPublisher as Mock).mockImplementation(mockedInitPublisher); (hasMediaProcessorSupport as Mock).mockImplementation(mockedHasMediaProcessorSupport); - vi.mocked(useDevices).mockReturnValue({ - getAllMediaDevices: vi.fn(), - allMediaDevices, - }); + vi.mocked(useDevices).mockReturnValue({ getAllMediaDevices: vi.fn(), allMediaDevices }); vi.mocked(usePermissions).mockReturnValue({ accessStatus: DEVICE_ACCESS_STATUS.PENDING, setAccessStatus: mockSetAccessStatus, }); - vi.mocked(usePublisherOptions).mockReturnValue({ - publishVideo: true, - }); }); describe('initBackgroundLocalPublisher', () => { it('should call initBackgroundLocalPublisher', async () => { mockedInitPublisher.mockReturnValue(mockPublisher); - const { result } = renderHook(() => useBackgroundPublisher()); - + const { result } = await renderHook(() => useBackgroundPublisher()); await result.current.initBackgroundLocalPublisher(); - expect(mockedInitPublisher).toHaveBeenCalled(); }); @@ -82,7 +71,7 @@ describe('useBackgroundPublisher', () => { callback(error); }); - const { result } = renderHook(() => useBackgroundPublisher()); + const { result } = await renderHook(() => useBackgroundPublisher()); await result.current.initBackgroundLocalPublisher(); expect(console.error).toHaveBeenCalledWith('initPublisher error: ', error); }); @@ -90,7 +79,7 @@ describe('useBackgroundPublisher', () => { it('should apply background high blur when initialized and changed background', async () => { mockedHasMediaProcessorSupport.mockReturnValue(true); mockedInitPublisher.mockReturnValue(mockPublisher); - const { result } = renderHook(() => useBackgroundPublisher()); + const { result } = await renderHook(() => useBackgroundPublisher()); await result.current.initBackgroundLocalPublisher(); await act(async () => { @@ -105,24 +94,22 @@ describe('useBackgroundPublisher', () => { it('should not replace background when initialized if the device does not support it', async () => { mockedHasMediaProcessorSupport.mockReturnValue(false); mockedInitPublisher.mockReturnValue(mockPublisher); - const { result } = renderHook(() => useBackgroundPublisher()); + const { result } = await renderHook(() => useBackgroundPublisher()); await result.current.initBackgroundLocalPublisher(); expect(mockedInitPublisher).toHaveBeenCalledWith( undefined, - expect.objectContaining({ - videoFilter: undefined, - }), + expect.objectContaining({ videoFilter: undefined }), expect.any(Function) ); }); }); describe('changeBackground', () => { - let result: ReturnType['result']; + let result: Awaited>['result']; beforeEach(async () => { mockedHasMediaProcessorSupport.mockReturnValue(true); mockedInitPublisher.mockReturnValue(mockPublisher); - result = renderHook(() => useBackgroundPublisher()).result; + result = (await renderHook(() => useBackgroundPublisher())).result; await act(async () => { await ( result.current as ReturnType @@ -169,7 +156,7 @@ describe('useBackgroundPublisher', () => { throw new Error('Simulated internal failure'); }); - const { result: res } = renderHook(() => useBackgroundPublisher()); + const { result: res } = await renderHook(() => useBackgroundPublisher()); await act(async () => { await res.current.initBackgroundLocalPublisher(); await res.current.changeBackground('low-blur'); @@ -191,17 +178,12 @@ describe('useBackgroundPublisher', () => { }; beforeEach(() => { - mockedPermissionStatus = { - onchange: null, - status: 'prompt', - }; + mockedPermissionStatus = { onchange: null, status: 'prompt' }; mockQuery.mockResolvedValue(mockedPermissionStatus); Object.defineProperty(global.navigator, 'permissions', { writable: true, - value: { - query: mockQuery, - }, + value: { query: mockQuery }, }); }); @@ -212,10 +194,10 @@ describe('useBackgroundPublisher', () => { }); }); - it('handles permission denial', () => { + it('handles permission denial', async () => { mockedInitPublisher.mockReturnValue(mockPublisher); - const { result } = renderHook(() => useBackgroundPublisher()); + const { result } = await renderHook(() => useBackgroundPublisher()); act(() => { result.current.initBackgroundLocalPublisher(); @@ -227,13 +209,13 @@ describe('useBackgroundPublisher', () => { expect(mockSetAccessStatus).toBeCalledWith(DEVICE_ACCESS_STATUS.REJECTED); }); - it('does not throw on older, unsupported browsers', () => { + it('does not throw on older, unsupported browsers', async () => { mockQuery.mockImplementation(() => { throw new Error('Whoops'); }); mockedInitPublisher.mockReturnValue(mockPublisher); - const { result } = renderHook(() => useBackgroundPublisher()); + const { result } = await renderHook(() => useBackgroundPublisher()); act(() => { result.current.initBackgroundLocalPublisher(); @@ -249,5 +231,9 @@ describe('useBackgroundPublisher', () => { }); function renderHook(render: (initialProps: Props) => Result) { - return renderHookBase(render, { wrapper: appConfig.Provider }); + const { AppConfigWrapper } = makeAppConfigProviderWrapper(); + + const composedWrapper = composeProviders(Suspense$, AppConfigWrapper); + + return act(() => renderHookBase(render, { wrapper: composedWrapper })); } diff --git a/frontend/src/Context/BackgroundPublisherProvider/useBackgroundPublisher/useBackgroundPublisher.tsx b/frontend/src/Context/BackgroundPublisherProvider/useBackgroundPublisher/useBackgroundPublisher.tsx index ca6d204cc..a8c77a87f 100644 --- a/frontend/src/Context/BackgroundPublisherProvider/useBackgroundPublisher/useBackgroundPublisher.tsx +++ b/frontend/src/Context/BackgroundPublisherProvider/useBackgroundPublisher/useBackgroundPublisher.tsx @@ -7,14 +7,15 @@ import { hasMediaProcessorSupport, PublisherProperties, } from '@vonage/client-sdk-video'; -import setMediaDevices from '../../../utils/mediaDeviceUtils'; -import useDevices from '../../../hooks/useDevices'; -import usePermissions from '../../../hooks/usePermissions'; -import useUserContext from '../../../hooks/useUserContext'; -import { DEVICE_ACCESS_STATUS } from '../../../utils/constants'; +import useSuspenseUntilAppConfigReady from '@Context/AppConfig/hooks/useSuspenseUntilAppConfigReady'; +import setMediaDevices from '@utils/mediaDeviceUtils'; +import useDevices from '@hooks/useDevices'; +import usePermissions from '@hooks/usePermissions'; +import useUserContext from '@hooks/useUserContext'; +import { DEVICE_ACCESS_STATUS } from '@utils/constants'; +import DeviceStore from '@utils/DeviceStore'; +import applyBackgroundFilter from '@utils/backgroundFilter/applyBackgroundFilter/applyBackgroundFilter'; import { AccessDeniedEvent } from '../../PublisherProvider/usePublisher/usePublisher'; -import DeviceStore from '../../../utils/DeviceStore'; -import applyBackgroundFilter from '../../../utils/backgroundFilter/applyBackgroundFilter/applyBackgroundFilter'; export type BackgroundPublisherContextType = { isPublishing: boolean; @@ -53,6 +54,8 @@ type PublisherVideoElementCreatedEvent = Event<'videoElementCreated', Publisher> * @returns {BackgroundPublisherContextType} Background context */ const useBackgroundPublisher = (): BackgroundPublisherContextType => { + useSuspenseUntilAppConfigReady(); + const { user } = useUserContext(); const { allMediaDevices, getAllMediaDevices } = useDevices(); const [publisherVideoElement, setPublisherVideoElement] = useState< diff --git a/frontend/src/Context/Device/DevicesContext.ts b/frontend/src/Context/Device/DevicesContext.ts new file mode 100644 index 000000000..49a8b7818 --- /dev/null +++ b/frontend/src/Context/Device/DevicesContext.ts @@ -0,0 +1,52 @@ +import createGlobalState, { type InferStateApi } from 'react-global-state-hooks/createGlobalState'; +import { Device, AudioOutputDevice } from '@vonage/client-sdk-video'; +import getConnectedDeviceId from './actions/getConnectedDeviceId'; +import updateMediaDevices from './actions/updateMediaDevices'; + +const initialValue = { + devices: [] as Device[], + audioOutputDevices: [] as AudioOutputDevice[], +}; + +const metadata = { + loadingDevices: null as null | Promise, + loadingAudioOutputDevices: null as null | Promise, + + /** + * MediaDevices doesn't need to be reactive, so we keep it in metadata + */ + mediaDevices: [] as MediaDeviceInfo[], + loadingMediaDevices: null as null | Promise, + + updateMediaDevices: null as null | Promise, +}; + +/** + * [TODO]: For easy testing is better to use context but it will require more refactor... We'll take care of it later. + */ +const devices$ = createGlobalState(initialValue, { + metadata, + actions: { + getConnectedDeviceId, + updateMediaDevices, + }, + callbacks: { + onInit: ({ actions }) => { + const { mediaDevices } = window.navigator; + if (!mediaDevices || !mediaDevices.addEventListener) { + console.warn('enumerateDevices() not supported.'); + return; + } + + // keep all devices updated on devicechange event + mediaDevices.addEventListener('devicechange', actions.updateMediaDevices); + + // Initial load + void actions.updateMediaDevices(); + }, + }, +}); + +export type DevicesApi = InferStateApi; + +export default devices$; diff --git a/frontend/src/Context/Device/actions/getConnectedDeviceId.ts b/frontend/src/Context/Device/actions/getConnectedDeviceId.ts new file mode 100644 index 000000000..e48ef0014 --- /dev/null +++ b/frontend/src/Context/Device/actions/getConnectedDeviceId.ts @@ -0,0 +1,32 @@ +import { STORAGE_KEYS, getStorageItem } from '@utils/storage'; + +export type DevicesApi = import('../DevicesContext').DevicesApi; + +export type DeviceKind = 'audioinput' | 'videoinput'; + +/** + * Device store that retrieves the stored device ID for a given device type (audio or video) + * and checks if it is still connected. + */ +function getConnectedDeviceId(this: DevicesApi['actions'], kind: DeviceKind) { + return async ({ getMetadata }: DevicesApi): Promise => { + const meta = getMetadata(); + + if (meta.loadingMediaDevices) { + await meta.loadingMediaDevices; + } + + const key = kind === 'videoinput' ? STORAGE_KEYS.VIDEO_SOURCE : STORAGE_KEYS.AUDIO_SOURCE; + + const storedId = getStorageItem(key); + + const deviceId: string | null = + meta.mediaDevices.find( + (device) => device.kind === kind && (!storedId || device.deviceId === storedId) + )?.deviceId ?? null; + + return deviceId; + }; +} + +export default getConnectedDeviceId; diff --git a/frontend/src/Context/Device/actions/updateMediaDevices.ts b/frontend/src/Context/Device/actions/updateMediaDevices.ts new file mode 100644 index 000000000..c9f19bf28 --- /dev/null +++ b/frontend/src/Context/Device/actions/updateMediaDevices.ts @@ -0,0 +1,68 @@ +import getDevices from '../helpers/getDevices'; +import getAudioOutputDevices from '../helpers/getAudioOutputDevices'; + +export type DevicesApi = import('../DevicesContext').DevicesApi; + +/** + * Device store that retrieves the stored device ID for a given device type (audio or video) + * and checks if it is still connected. + */ +function updateMediaDevices(this: DevicesApi['actions']) { + return async ({ getMetadata, setState, getState }: DevicesApi): Promise => { + const meta = getMetadata(); + + // If there is an ongoing update queue the next update so the last call doesn't get overridden + if (meta.updateMediaDevices) { + return meta.updateMediaDevices.then(() => this.updateMediaDevices()); + } + + const loadDevices = () => { + return (meta.loadingDevices = getDevices().then((devices) => { + meta.loadingDevices = null; + + setState({ + ...getState(), + devices, + }); + + return devices; + })); + }; + + const loadAudioOutputDevices = () => { + return (meta.loadingAudioOutputDevices = getAudioOutputDevices().then( + (audioOutputDevices) => { + meta.loadingAudioOutputDevices = null; + + setState({ + ...getState(), + audioOutputDevices, + }); + + return audioOutputDevices; + } + )); + }; + + const loadMediaDevices = () => { + return (meta.loadingMediaDevices = navigator.mediaDevices + .enumerateDevices() + .then((devices) => { + meta.mediaDevices = devices; + meta.loadingMediaDevices = null; + + return devices; + })); + }; + + await (meta.updateMediaDevices = Promise.all([ + loadDevices(), + loadAudioOutputDevices(), + loadMediaDevices(), + ]).then(() => { + meta.updateMediaDevices = null; + })); + }; +} + +export default updateMediaDevices; diff --git a/frontend/src/Context/Device/helpers/getAudioOutputDevices.ts b/frontend/src/Context/Device/helpers/getAudioOutputDevices.ts new file mode 100644 index 000000000..0eaa9d7ac --- /dev/null +++ b/frontend/src/Context/Device/helpers/getAudioOutputDevices.ts @@ -0,0 +1,17 @@ +import { getAudioOutputDevices as getVonageAudioOutputDevices } from '@vonage/client-sdk-video'; +import renameDefaultAudioOutputDevice from '@utils/renameDefaultAudioOutputDevice'; +import i18n from '../../../i18n'; + +const getAudioOutputDevices = () => { + const t = i18n.t; + + // Vonage Video API's getAudioOutputDevices retrieves all audio output devices (speakers) + return getVonageAudioOutputDevices().then((audioOutputDevices) => { + // Rename the label of the default audio output to "System Default" + return audioOutputDevices.map((device) => + renameDefaultAudioOutputDevice(device, t('devices.audio.defaultLabel')) + ); + }); +}; + +export default getAudioOutputDevices; diff --git a/frontend/src/Context/Device/helpers/getDevices.ts b/frontend/src/Context/Device/helpers/getDevices.ts new file mode 100644 index 000000000..5899a13a5 --- /dev/null +++ b/frontend/src/Context/Device/helpers/getDevices.ts @@ -0,0 +1,18 @@ +import { getDevices as getVonageDevices, Device, OTError } from '@vonage/client-sdk-video'; + +/** + * Helper to get all media meta using Vonage Video API's getDevices method + */ +const getDevices = () => + new Promise((resolve, reject) => { + getVonageDevices((err?: OTError, devices?: Device[]) => { + if (err || !devices) { + reject(err ?? new Error('Failed to get devices')); + return; + } + + resolve(devices); + }); + }); + +export default getDevices; diff --git a/frontend/src/Context/Device/hooks/useAudioInputDevices.ts b/frontend/src/Context/Device/hooks/useAudioInputDevices.ts new file mode 100644 index 000000000..31880a609 --- /dev/null +++ b/frontend/src/Context/Device/hooks/useAudioInputDevices.ts @@ -0,0 +1,8 @@ +import isAudioInputDevice from '@utils/isAudioInputDevice'; +import devices$ from '../DevicesContext'; + +const useAudioInputDevices = devices$.createSelectorHook((state) => + state.devices.filter(isAudioInputDevice) +); + +export default useAudioInputDevices; diff --git a/frontend/src/Context/Device/hooks/useAudioOutputDevices.ts b/frontend/src/Context/Device/hooks/useAudioOutputDevices.ts new file mode 100644 index 000000000..effb9c955 --- /dev/null +++ b/frontend/src/Context/Device/hooks/useAudioOutputDevices.ts @@ -0,0 +1,5 @@ +import devices$ from '../DevicesContext'; + +const useAudioOutputDevices = devices$.createSelectorHook((state) => state.audioOutputDevices); + +export default useAudioOutputDevices; diff --git a/frontend/src/Context/Device/hooks/useConnectedDeviceId.ts b/frontend/src/Context/Device/hooks/useConnectedDeviceId.ts new file mode 100644 index 000000000..8f3b350be --- /dev/null +++ b/frontend/src/Context/Device/hooks/useConnectedDeviceId.ts @@ -0,0 +1,27 @@ +import useSuspenseMemo from '@hooks/useSuspenseMemo'; +import devices$ from '../DevicesContext'; +import type { DeviceKind } from '../actions/getConnectedDeviceId'; + +/** + * Returns the id of the connected device for the given kind ('audioinput' | 'videoinput') + * The Id retrieval is asynchronous and will suspend the component until the id is available. + */ +function useConnectedDeviceId(kind: DeviceKind): string | null; + +/** + * Returns the ids of the connected devices for the given kinds ('audioinput' | 'videoinput') + * The Ids retrieval is asynchronous and will suspend the component until the ids are available. + */ +function useConnectedDeviceId(...kinds: DeviceKind[]): (string | null)[]; + +function useConnectedDeviceId(...kinds: DeviceKind[]): string | null | (string | null)[] { + return useSuspenseMemo(async () => { + const results = await Promise.all( + kinds.map((kind) => devices$.actions.getConnectedDeviceId(kind)) + ); + + return kinds.length === 1 ? results[0] : results; + }, kinds); +} + +export default useConnectedDeviceId; diff --git a/frontend/src/Context/Device/hooks/useVideoInputDevices.ts b/frontend/src/Context/Device/hooks/useVideoInputDevices.ts new file mode 100644 index 000000000..131379307 --- /dev/null +++ b/frontend/src/Context/Device/hooks/useVideoInputDevices.ts @@ -0,0 +1,8 @@ +import isVideoInputDevice from '@utils/isVideoInputDevice'; +import devices$ from '../DevicesContext'; + +const useVideoInputDevices = devices$.createSelectorHook((state) => + state.devices.filter(isVideoInputDevice) +); + +export default useVideoInputDevices; diff --git a/frontend/src/Context/Device/index.ts b/frontend/src/Context/Device/index.ts new file mode 100644 index 000000000..98824f34f --- /dev/null +++ b/frontend/src/Context/Device/index.ts @@ -0,0 +1 @@ +export * from './DevicesContext'; diff --git a/frontend/src/Context/PreviewPublisherProvider/index.tsx b/frontend/src/Context/PreviewPublisherProvider/index.tsx index 949b5f4f5..c9ad9cee6 100644 --- a/frontend/src/Context/PreviewPublisherProvider/index.tsx +++ b/frontend/src/Context/PreviewPublisherProvider/index.tsx @@ -1,4 +1,5 @@ -import { ReactNode, createContext, useMemo } from 'react'; +import { ReactNode, createContext } from 'react'; +import useSuspenseUntilAppConfigReady from '@Context/AppConfig/hooks/useSuspenseUntilAppConfigReady'; import usePreviewPublisher from './usePreviewPublisher'; export type PreviewPublisherContextType = ReturnType; @@ -19,9 +20,13 @@ export type PreviewPublisherProviderProps = { * @returns {PreviewPublisherContext} a context provider for a publisher preview */ export const PreviewPublisherProvider = ({ children }: { children: ReactNode }) => { + useSuspenseUntilAppConfigReady(); + const previewPublisherContext = usePreviewPublisher(); - const value = useMemo(() => previewPublisherContext, [previewPublisherContext]); + return ( - {children} + + {children} + ); }; diff --git a/frontend/src/Context/PreviewPublisherProvider/usePreviewPublisher/usePreviewPublisher.spec.tsx b/frontend/src/Context/PreviewPublisherProvider/usePreviewPublisher/usePreviewPublisher.spec.tsx index 7eb49952d..cd07d4c84 100644 --- a/frontend/src/Context/PreviewPublisherProvider/usePreviewPublisher/usePreviewPublisher.spec.tsx +++ b/frontend/src/Context/PreviewPublisherProvider/usePreviewPublisher/usePreviewPublisher.spec.tsx @@ -7,9 +7,11 @@ 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 composeProviders from '@utils/composeProviders'; +import Suspense$ from '@Context/Suspense$'; +import { makeAppConfigProviderWrapper } from '@test/providers'; import usePreviewPublisher from './usePreviewPublisher'; +import { UserContextType } from '../../user'; vi.mock('@vonage/client-sdk-video'); vi.mock('@hooks/useUserContext.tsx'); @@ -24,10 +26,7 @@ const defaultSettings = { publishCaptions: false, }; const mockUserContextWithDefaultSettings = { - user: { - defaultSettings, - issues: { reconnections: 0, audioFallbacks: 0 }, - }, + user: { defaultSettings, issues: { reconnections: 0, audioFallbacks: 0 } }, setUser: vi.fn(), } as UserContextType; @@ -48,10 +47,7 @@ describe('usePreviewPublisher', () => { vi.mocked(useUserContext).mockImplementation(() => mockUserContextWithDefaultSettings); (initPublisher as Mock).mockImplementation(mockedInitPublisher); (hasMediaProcessorSupport as Mock).mockImplementation(mockedHasMediaProcessorSupport); - vi.mocked(useDevices).mockReturnValue({ - getAllMediaDevices: vi.fn(), - allMediaDevices, - }); + vi.mocked(useDevices).mockReturnValue({ getAllMediaDevices: vi.fn(), allMediaDevices }); vi.mocked(usePermissions).mockReturnValue({ accessStatus: DEVICE_ACCESS_STATUS.PENDING, setAccessStatus: mockSetAccessStatus, @@ -61,7 +57,8 @@ describe('usePreviewPublisher', () => { describe('initLocalPublisher', () => { it('should call initLocalPublisher', async () => { mockedInitPublisher.mockReturnValue(mockPublisher); - const { result } = renderHook(() => usePreviewPublisher()); + + const { result } = await renderHook(() => usePreviewPublisher()); await result.current.initLocalPublisher(); @@ -77,7 +74,7 @@ describe('usePreviewPublisher', () => { callback(error); }); - const { result } = renderHook(() => usePreviewPublisher()); + const { result } = await renderHook(() => usePreviewPublisher()); await result.current.initLocalPublisher(); expect(console.error).toHaveBeenCalledWith('initPublisher error: ', error); }); @@ -85,7 +82,7 @@ describe('usePreviewPublisher', () => { it('should apply background high blur when initialized and changed background', async () => { mockedHasMediaProcessorSupport.mockReturnValue(true); mockedInitPublisher.mockReturnValue(mockPublisher); - const { result } = renderHook(() => usePreviewPublisher()); + const { result } = await renderHook(() => usePreviewPublisher()); await result.current.initLocalPublisher(); await act(async () => { @@ -100,24 +97,25 @@ describe('usePreviewPublisher', () => { it('should not replace background when initialized if the device does not support it', async () => { mockedHasMediaProcessorSupport.mockReturnValue(false); mockedInitPublisher.mockReturnValue(mockPublisher); - const { result } = renderHook(() => usePreviewPublisher()); + const { result } = await renderHook(() => usePreviewPublisher()); await result.current.initLocalPublisher(); expect(mockedInitPublisher).toHaveBeenCalledWith( undefined, - expect.objectContaining({ - videoFilter: undefined, - }), + expect.objectContaining({ videoFilter: undefined }), expect.any(Function) ); }); }); describe('changeBackground', () => { - let result: ReturnType['result']; + let result: Awaited>['result']; + beforeEach(async () => { mockedHasMediaProcessorSupport.mockReturnValue(true); mockedInitPublisher.mockReturnValue(mockPublisher); - result = renderHook(() => usePreviewPublisher()).result; + + ({ result } = await renderHook(() => usePreviewPublisher())); + await act(async () => { await (result.current as ReturnType).initLocalPublisher(); }); @@ -160,7 +158,7 @@ describe('usePreviewPublisher', () => { throw new Error('Simulated internal failure'); }); - const { result: res } = renderHook(() => usePreviewPublisher()); + const { result: res } = await renderHook(() => usePreviewPublisher()); await act(async () => { await res.current.initLocalPublisher(); await res.current.changeBackground('low-blur'); @@ -182,17 +180,12 @@ describe('usePreviewPublisher', () => { }; beforeEach(() => { - mockedPermissionStatus = { - onchange: null, - status: 'prompt', - }; + mockedPermissionStatus = { onchange: null, status: 'prompt' }; mockQuery.mockResolvedValue(mockedPermissionStatus); Object.defineProperty(global.navigator, 'permissions', { writable: true, - value: { - query: mockQuery, - }, + value: { query: mockQuery }, }); }); @@ -203,10 +196,10 @@ describe('usePreviewPublisher', () => { }); }); - it('handles permission denial', () => { + it('handles permission denial', async () => { mockedInitPublisher.mockReturnValue(mockPublisher); - const { result } = renderHook(() => usePreviewPublisher()); + const { result } = await renderHook(() => usePreviewPublisher()); act(() => { result.current.initLocalPublisher(); @@ -218,13 +211,13 @@ describe('usePreviewPublisher', () => { expect(mockSetAccessStatus).toBeCalledWith(DEVICE_ACCESS_STATUS.REJECTED); }); - it('does not throw on older, unsupported browsers', () => { + it('does not throw on older, unsupported browsers', async () => { mockQuery.mockImplementation(() => { throw new Error('Whoops'); }); mockedInitPublisher.mockReturnValue(mockPublisher); - const { result } = renderHook(() => usePreviewPublisher()); + const { result } = await renderHook(() => usePreviewPublisher()); act(() => { result.current.initLocalPublisher(); @@ -240,5 +233,11 @@ describe('usePreviewPublisher', () => { }); function renderHook(render: (initialProps: Props) => Result) { - return renderHookBase(render, { wrapper: appConfig.Provider }); + const { AppConfigWrapper } = makeAppConfigProviderWrapper(); + + const wrapper = composeProviders(Suspense$, AppConfigWrapper); + + return act(() => { + return renderHookBase(render, { wrapper }); + }); } diff --git a/frontend/src/Context/PreviewPublisherProvider/usePreviewPublisher/usePreviewPublisher.tsx b/frontend/src/Context/PreviewPublisherProvider/usePreviewPublisher/usePreviewPublisher.tsx index 89333614c..072980856 100644 --- a/frontend/src/Context/PreviewPublisherProvider/usePreviewPublisher/usePreviewPublisher.tsx +++ b/frontend/src/Context/PreviewPublisherProvider/usePreviewPublisher/usePreviewPublisher.tsx @@ -8,6 +8,9 @@ import { PublisherProperties, } from '@vonage/client-sdk-video'; import useAppConfig from '@Context/AppConfig/hooks/useAppConfig'; +import useSuspenseUntilAppConfigReady from '@Context/AppConfig/hooks/useSuspenseUntilAppConfigReady'; +import useIsCameraControlAllowed from '@Context/AppConfig/hooks/useIsCameraControlAllowed'; +import useIsMicrophoneControlAllowed from '@Context/AppConfig/hooks/useIsMicrophoneControlAllowed'; import setMediaDevices from '../../../utils/mediaDeviceUtils'; import useDevices from '../../../hooks/useDevices'; import usePermissions from '../../../hooks/usePermissions'; @@ -66,6 +69,8 @@ export type PreviewPublisherContextType = { * @returns {PreviewPublisherContextType} preview context */ const usePreviewPublisher = (): PreviewPublisherContextType => { + useSuspenseUntilAppConfigReady(); + const { setUser, user } = useUserContext(); const defaultResolution = useAppConfig(({ videoSettings }) => videoSettings.defaultResolution); const { allMediaDevices, getAllMediaDevices } = useDevices(); @@ -79,11 +84,17 @@ const usePreviewPublisher = (): PreviewPublisherContextType => { const initialBackgroundRef = useRef( user.defaultSettings.backgroundFilter ); + const [backgroundFilter, setBackgroundFilter] = useState( user.defaultSettings.backgroundFilter ); - const [isVideoEnabled, setIsVideoEnabled] = useState(true); - const [isAudioEnabled, setIsAudioEnabled] = useState(true); + + const isCameraAllowed = useIsCameraControlAllowed(); + const isMicrophoneAllowed = useIsMicrophoneControlAllowed(); + + const [isVideoEnabled, setIsVideoEnabled] = useState(isCameraAllowed); + const [isAudioEnabled, setIsAudioEnabled] = useState(isMicrophoneAllowed); + const [localVideoSource, setLocalVideoSource] = useState(undefined); const [localAudioSource, setLocalAudioSource] = useState(undefined); const deviceStoreRef = useRef(new DeviceStore()); @@ -210,6 +221,7 @@ const usePreviewPublisher = (): PreviewPublisherContextType => { publisher.on('audioLevelUpdated', ({ audioLevel }: { audioLevel: number }) => { calculateAudioLevel(audioLevel); }); + publisher.on('accessAllowed', () => { setAccessStatus(DEVICE_ACCESS_STATUS.ACCEPTED); getAllMediaDevices(); @@ -233,6 +245,7 @@ const usePreviewPublisher = (): PreviewPublisherContextType => { } await deviceStoreRef.current.init(); + const videoSource = deviceStoreRef.current.getConnectedDeviceId('videoinput'); const audioSource = deviceStoreRef.current.getConnectedDeviceId('audioinput'); @@ -278,13 +291,15 @@ const usePreviewPublisher = (): PreviewPublisherContextType => { if (!publisherRef.current) { return; } - publisherRef.current.publishVideo(!isVideoEnabled); - setStorageItem(STORAGE_KEYS.VIDEO_SOURCE_ENABLED, (!isVideoEnabled).toString()); - setIsVideoEnabled(!isVideoEnabled); + const newIsVideoEnabled = !isVideoEnabled; + + publisherRef.current.publishVideo(newIsVideoEnabled); + setStorageItem(STORAGE_KEYS.VIDEO_SOURCE_ENABLED, newIsVideoEnabled.toString()); + setIsVideoEnabled(newIsVideoEnabled); if (setUser) { setUser((prevUser: UserType) => ({ ...prevUser, - defaultSettings: { ...prevUser.defaultSettings, publishVideo: !isVideoEnabled }, + defaultSettings: { ...prevUser.defaultSettings, publishVideo: newIsVideoEnabled }, })); } }; @@ -299,13 +314,15 @@ const usePreviewPublisher = (): PreviewPublisherContextType => { if (!publisherRef.current) { return; } - publisherRef.current.publishAudio(!isAudioEnabled); - setIsAudioEnabled(!isAudioEnabled); - setStorageItem(STORAGE_KEYS.AUDIO_SOURCE_ENABLED, (!isAudioEnabled).toString()); + + const newIsAudioEnabled = !isAudioEnabled; + publisherRef.current.publishAudio(newIsAudioEnabled); + setIsAudioEnabled(newIsAudioEnabled); + setStorageItem(STORAGE_KEYS.AUDIO_SOURCE_ENABLED, newIsAudioEnabled.toString()); if (setUser) { setUser((prevUser: UserType) => ({ ...prevUser, - defaultSettings: { ...prevUser.defaultSettings, publishAudio: !isAudioEnabled }, + defaultSettings: { ...prevUser.defaultSettings, publishAudio: newIsAudioEnabled }, })); } }; diff --git a/frontend/src/Context/PublisherProvider/index.tsx b/frontend/src/Context/PublisherProvider/index.tsx index 7094ca66a..804815cf0 100644 --- a/frontend/src/Context/PublisherProvider/index.tsx +++ b/frontend/src/Context/PublisherProvider/index.tsx @@ -1,11 +1,10 @@ -import { ReactElement, ReactNode, createContext, useMemo } from 'react'; +import { ReactElement, ReactNode, createContext } from 'react'; +import useSuspenseUntilAppConfigReady from '@Context/AppConfig/hooks/useSuspenseUntilAppConfigReady'; import usePublisher from './usePublisher'; export type PublisherContextType = ReturnType; export const PublisherContext = createContext({} as PublisherContextType); -export type PublisherProviderProps = { - children: ReactNode; -}; +export type PublisherProviderProps = { children: ReactNode }; /** * PublisherProvider - React Context Provider for PublisherContext * PublisherContext contains all state and methods for local video publisher @@ -17,7 +16,8 @@ export type PublisherProviderProps = { * @returns {PublisherContextType} a context provider for a publisher */ export const PublisherProvider = ({ children }: PublisherProviderProps): ReactElement => { + useSuspenseUntilAppConfigReady(); + const publisherContext = usePublisher(); - const value = useMemo(() => publisherContext, [publisherContext]); - return {children}; + return {children}; }; diff --git a/frontend/src/Context/PublisherProvider/usePublisher/index.tsx b/frontend/src/Context/PublisherProvider/usePublisher/index.tsx index f8b43ba5b..076f5c595 100644 --- a/frontend/src/Context/PublisherProvider/usePublisher/index.tsx +++ b/frontend/src/Context/PublisherProvider/usePublisher/index.tsx @@ -1,3 +1,2 @@ -import usePublisher from './usePublisher'; - -export default usePublisher; +export * from './usePublisher'; +export { default } from './usePublisher'; diff --git a/frontend/src/Context/PublisherProvider/usePublisher/usePublisher.spec.tsx b/frontend/src/Context/PublisherProvider/usePublisher/usePublisher.spec.tsx index ce3eec10a..16786d472 100644 --- a/frontend/src/Context/PublisherProvider/usePublisher/usePublisher.spec.tsx +++ b/frontend/src/Context/PublisherProvider/usePublisher/usePublisher.spec.tsx @@ -9,10 +9,13 @@ 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 makeAppConfigProviderWrapper from '@test/providers/makeAppConfigProviderWrapper'; +import composeProviders from '@utils/composeProviders'; +import Suspense$ from '@Context/Suspense$/SuspenseContext'; +import { setStorageItem, STORAGE_KEYS } from '@utils/storage'; import { SessionContextType } from '../../SessionProvider/session'; +import { UserContextType } from '../../user'; +import usePublisher from './usePublisher'; vi.mock('@vonage/client-sdk-video'); vi.mock('@hooks/useUserContext.tsx'); @@ -26,16 +29,10 @@ const defaultSettings = { publishCaptions: false, }; const mockUserContextWithDefaultSettings = { - user: { - defaultSettings, - issues: { reconnections: 0, audioFallbacks: 0 }, - }, + user: { defaultSettings, issues: { reconnections: 0, audioFallbacks: 0 } }, setUser: vi.fn(), } as UserContextType; -const mockStream = { - streamId: 'stream-id', - name: 'Jane Doe', -} as unknown as Stream; +const mockStream = { streamId: 'stream-id', name: 'Jane Doe' } as unknown as Stream; describe('usePublisher', () => { const destroySpy = vi.fn(); @@ -69,7 +66,7 @@ describe('usePublisher', () => { describe('initializeLocalPublisher', () => { it('should call initPublisher', async () => { - const { result } = renderHook(() => usePublisher()); + const { result } = await renderHook(() => usePublisher()); act(() => { result.current.initializeLocalPublisher({}); }); @@ -84,7 +81,7 @@ describe('usePublisher', () => { throw new Error('The second mouse gets the cheese.'); }); - const { result } = renderHook(() => usePublisher()); + const { result } = await renderHook(() => usePublisher()); act(() => { result.current.initializeLocalPublisher({}); }); @@ -99,7 +96,7 @@ describe('usePublisher', () => { it('should unpublish when requested', async () => { vi.mocked(initPublisher).mockImplementation(() => mockPublisher); - const { result, rerender } = renderHook(() => usePublisher()); + const { result, rerender } = await renderHook(() => usePublisher()); act(() => { result.current.initializeLocalPublisher({}); @@ -119,12 +116,13 @@ describe('usePublisher', () => { }); describe('changeBackground', () => { - let result: ReturnType['result']; - beforeEach(() => { + let result: Awaited>['result']; + beforeEach(async () => { vi.mocked(initPublisher).mockImplementation(() => mockPublisher); - result = renderHook(() => usePublisher()).result; - act(() => { + result = (await renderHook(() => usePublisher())).result; + await act(() => { (result.current as ReturnType).initializeLocalPublisher({}); + return Promise.resolve(); }); }); @@ -160,7 +158,7 @@ describe('usePublisher', () => { it('should publish to the session', async () => { vi.mocked(initPublisher).mockImplementation(() => mockPublisher); - const { result } = renderHook(() => usePublisher()); + const { result } = await renderHook(() => usePublisher()); act(() => { result.current.initializeLocalPublisher({}); @@ -178,7 +176,7 @@ describe('usePublisher', () => { throw new Error('There is an error.'); }); - const { result } = renderHook(() => usePublisher()); + const { result } = await renderHook(() => usePublisher()); await act(async () => { result.current.initializeLocalPublisher({}); @@ -193,7 +191,7 @@ describe('usePublisher', () => { it('should only publish to session once', async () => { vi.mocked(initPublisher).mockImplementation(() => mockPublisher); - const { result } = renderHook(() => usePublisher()); + const { result } = await renderHook(() => usePublisher()); act(() => { result.current.initializeLocalPublisher({}); @@ -220,7 +218,7 @@ describe('usePublisher', () => { mockedSessionPublish.mockImplementation((_, callback) => { callback(new Error('Mocked error')); }); - const { result } = renderHook(() => usePublisher()); + const { result } = await renderHook(() => usePublisher()); act(() => { result.current.initializeLocalPublisher({}); @@ -236,13 +234,13 @@ describe('usePublisher', () => { "We're having trouble connecting you with others in the meeting room. Please check your network and try again.", }; expect(result.current.publishingError).toEqual(publishingBlockedError); - expect(mockedSessionPublish).toHaveBeenCalledTimes(2); + expect(mockedSessionPublish).toHaveBeenCalledTimes(3); }); }); it('should set publishingError and destroy publisher when receiving an accessDenied event', async () => { vi.mocked(initPublisher).mockImplementation(() => mockPublisher); - const { result } = renderHook(() => usePublisher()); + const { result } = await renderHook(() => usePublisher()); act(() => { result.current.initializeLocalPublisher({}); @@ -270,7 +268,7 @@ describe('usePublisher', () => { it('should not set publishingError when receiving an accessAllowed event', async () => { vi.mocked(initPublisher).mockImplementation(() => mockPublisher); - const { result } = renderHook(() => usePublisher()); + const { result } = await renderHook(() => usePublisher()); act(() => { result.current.initializeLocalPublisher({}); @@ -284,8 +282,36 @@ describe('usePublisher', () => { expect(result.current.publisher).toBe(mockPublisher); }); }); + + it('should disable audio and video from storage options', async () => { + vi.spyOn(OT, 'hasMediaProcessorSupport').mockReturnValue(true); + + setStorageItem(STORAGE_KEYS.AUDIO_SOURCE_ENABLED, 'false'); + setStorageItem(STORAGE_KEYS.VIDEO_SOURCE_ENABLED, 'true'); + + let { result } = await renderHook(() => usePublisher()); + + await waitFor(() => { + expect(result.current?.isAudioEnabled).toBe(false); + expect(result.current?.isVideoEnabled).toBe(true); + }); + + setStorageItem(STORAGE_KEYS.AUDIO_SOURCE_ENABLED, 'true'); + setStorageItem(STORAGE_KEYS.VIDEO_SOURCE_ENABLED, 'false'); + + ({ result } = await renderHook(() => usePublisher())); + + await waitFor(() => { + expect(result.current?.isAudioEnabled).toBe(true); + expect(result.current?.isVideoEnabled).toBe(false); + }); + }); }); function renderHook(render: (initialProps: Props) => Result) { - return renderHookBase(render, { wrapper: appConfig.Provider }); + const { AppConfigWrapper } = makeAppConfigProviderWrapper(); + + const composedWrapper = composeProviders(Suspense$, AppConfigWrapper); + + return act(() => renderHookBase(render, { wrapper: composedWrapper })); } diff --git a/frontend/src/Context/PublisherProvider/usePublisher/usePublisher.tsx b/frontend/src/Context/PublisherProvider/usePublisher/usePublisher.tsx index 1d196254b..2446472b6 100644 --- a/frontend/src/Context/PublisherProvider/usePublisher/usePublisher.tsx +++ b/frontend/src/Context/PublisherProvider/usePublisher/usePublisher.tsx @@ -8,33 +8,27 @@ import OT, { PublisherProperties, } from '@vonage/client-sdk-video'; import { useTranslation } from 'react-i18next'; -import { setStorageItem, STORAGE_KEYS } from '@utils/storage'; +import { getStorageItem, setStorageItem, STORAGE_KEYS } from '@utils/storage'; +import useSuspenseUntilAppConfigReady from '@Context/AppConfig/hooks/useSuspenseUntilAppConfigReady'; +import useUserContext from '@hooks/useUserContext'; +import isNil from 'lodash/isNil'; import usePublisherQuality, { NetworkQuality } from '../usePublisherQuality/usePublisherQuality'; import usePublisherOptions from '../usePublisherOptions'; import useSessionContext from '../../../hooks/useSessionContext'; import applyBackgroundFilter from '../../../utils/backgroundFilter/applyBackgroundFilter/applyBackgroundFilter'; +import idempotentCallbackWithRetry from '@utils/idempotentCallbackWithRetry/idempotentCallbackWithRetry'; -type PublisherStreamCreatedEvent = Event<'streamCreated', Publisher> & { - stream: Stream; -}; +type PublisherStreamCreatedEvent = Event<'streamCreated', Publisher> & { stream: Stream }; type PublisherVideoElementCreatedEvent = Event<'videoElementCreated', Publisher> & { element: HTMLVideoElement | HTMLObjectElement; }; -type DeviceAccessStatus = { - microphone: boolean | undefined; - camera: boolean | undefined; -}; +type DeviceAccessStatus = { microphone: boolean | undefined; camera: boolean | undefined }; -export type PublishingErrorType = { - header: string; - caption: string; -} | null; +export type PublishingErrorType = { header: string; caption: string } | null; -export type AccessDeniedEvent = Event<'accessDenied', Publisher> & { - message?: string; -}; +export type AccessDeniedEvent = Event<'accessDenied', Publisher> & { message?: string }; export type PublisherContextType = { initializeLocalPublisher: (options: PublisherProperties) => void; @@ -52,6 +46,7 @@ export type PublisherContextType = { toggleVideo: () => void; changeBackground: (backgroundSelected: string) => void; unpublish: () => void; + publisherOptions: PublisherProperties | null; }; /** @@ -75,26 +70,51 @@ export type PublisherContextType = { * @returns {PublisherContextType} the publisher context */ const usePublisher = (): PublisherContextType => { + useSuspenseUntilAppConfigReady(); + const { t } = useTranslation(); const [publisherVideoElement, setPublisherVideoElement] = useState< HTMLVideoElement | HTMLObjectElement >(); + + const { user } = useUserContext(); + const publisherRef = useRef(null); const quality = usePublisherQuality(publisherRef.current); const [isPublishing, setIsPublishing] = useState(false); - const publisherOptions = usePublisherOptions(); const [isForceMuted, setIsForceMuted] = useState(false); - const [isVideoEnabled, setIsVideoEnabled] = useState(false); - const [isAudioEnabled, setIsAudioEnabled] = useState(false); + + const [isVideoEnabled, setIsVideoEnabled] = useState(() => { + const localIsVideoEnabled = getStorageItem(STORAGE_KEYS.VIDEO_SOURCE_ENABLED); + + if (isNil(localIsVideoEnabled)) { + return user.defaultSettings.publishVideo; + } + + return localIsVideoEnabled === 'true'; + }); + + const [isAudioEnabled, setIsAudioEnabled] = useState(() => { + const localIsAudioEnabled = getStorageItem(STORAGE_KEYS.AUDIO_SOURCE_ENABLED); + + if (isNil(localIsAudioEnabled)) { + return user.defaultSettings.publishAudio; + } + + return localIsAudioEnabled === 'true'; + }); + + const publisherOptions = usePublisherOptions({ isVideoEnabled, isAudioEnabled }); + const [stream, setStream] = useState(); const [isPublishingToSession, setIsPublishingToSession] = useState(false); const [publishingError, setPublishingError] = useState(null); const { publish: sessionPublish, unpublish: sessionUnpublish, connected } = useSessionContext(); + const [deviceAccess, setDeviceAccess] = useState({ microphone: undefined, camera: undefined, }); - let publishAttempt: number = 0; // If we do not have audio input or video input access, we cannot publish. useEffect(() => { @@ -108,20 +128,8 @@ const usePublisher = (): PublisherContextType => { } }, [deviceAccess, t]); - useEffect(() => { - if (!publisherOptions) { - return; - } - - setIsVideoEnabled(!!publisherOptions.publishVideo); - setIsAudioEnabled(!!publisherOptions.publishAudio); - }, [publisherOptions]); - const handleAccessAllowed = () => { - setDeviceAccess({ - microphone: true, - camera: true, - }); + setDeviceAccess({ microphone: true, camera: true }); }; const handleDestroyed = () => { @@ -162,10 +170,7 @@ const usePublisher = (): PublisherContextType => { const handleAccessDenied = (event: AccessDeniedEvent) => { // We check the first word of the message to see if the microphone or camera was denied access. const deviceDeniedAccess = event.message?.startsWith('Microphone') ? 'microphone' : 'camera'; - setDeviceAccess((prev) => ({ - ...prev, - [deviceDeniedAccess]: false, - })); + setDeviceAccess((prev) => ({ ...prev, [deviceDeniedAccess]: false })); if (publisherRef.current) { publisherRef.current.destroy(); @@ -180,7 +185,6 @@ const usePublisher = (): PublisherContextType => { if (publisherRef?.current) { sessionUnpublish(publisherRef.current); setIsPublishingToSession(false); - publishAttempt = 0; } }; @@ -217,6 +221,7 @@ const usePublisher = (): PublisherContextType => { (options: PublisherProperties) => { try { const publisher = initPublisher(undefined, options); + // Add listeners synchronously as some events could be fired before callback is invoked addPublisherListeners(publisher); publisherRef.current = publisher; @@ -229,54 +234,39 @@ const usePublisher = (): PublisherContextType => { [addPublisherListeners] ); - /** - * Helper function to handle retrying. We allow two attempts when publishing to the session and encountering an - * error before stopping. - * @returns {boolean} Returns `true` if we've already retried twice, else `false` - */ - const shouldNotRetryPublish = (): boolean => { - publishAttempt += 1; - - if (publishAttempt === 3) { - const publishingBlocked: PublishingErrorType = { - header: t('publishingErrors.blocked.title'), - caption: t('publishingErrors.blocked.message'), - }; - setPublishingError(publishingBlocked); - setIsPublishingToSession(false); - return true; - } - return false; - }; - /** * Method to publish to session. * @returns {Promise} */ const publish = async (): Promise => { try { - if (!connected) { - throw new Error('You are not connected to session'); - } - if (!publisherRef.current) { - throw new Error('Publisher is not initialized'); - } if (isPublishingToSession) { return; } - if (shouldNotRetryPublish()) { - return; + if (!connected) { + throw new Error('You are not connected to session'); } - setIsPublishingToSession(true); // Avoid multiple simultaneous publish attempts - await sessionPublish(publisherRef.current); - } catch (err: unknown) { - if (err instanceof Error) { - console.warn(err); - setIsPublishingToSession(false); - publish(); + const publisher = publisherRef.current; + + if (!publisher) { + throw new Error('Publisher is not initialized'); } + + setIsPublishingToSession(true); + await idempotentCallbackWithRetry(() => sessionPublish(publisher), { + retries: 2, + delayMs: 200, + }); + } catch (err: unknown) { + const publishingBlocked: PublishingErrorType = { + header: t('publishingErrors.blocked.title'), + caption: t('publishingErrors.blocked.message'), + }; + + console.error('Error publishing to session:', err); + setPublishingError(publishingBlocked); } }; @@ -290,9 +280,12 @@ const usePublisher = (): PublisherContextType => { if (!publisherRef.current) { return; } - publisherRef.current.publishVideo(!isVideoEnabled); - setIsVideoEnabled(!isVideoEnabled); - setStorageItem(STORAGE_KEYS.VIDEO_SOURCE_ENABLED, (!isVideoEnabled).toString()); + + const newIsVideoEnabled = !isVideoEnabled; + + publisherRef.current.publishVideo(newIsVideoEnabled); + setIsVideoEnabled(newIsVideoEnabled); + setStorageItem(STORAGE_KEYS.VIDEO_SOURCE_ENABLED, newIsVideoEnabled.toString()); }; /** @@ -305,9 +298,11 @@ const usePublisher = (): PublisherContextType => { if (!publisherRef.current) { return; } - publisherRef.current.publishAudio(!isAudioEnabled); - setIsAudioEnabled(!isAudioEnabled); - setStorageItem(STORAGE_KEYS.AUDIO_SOURCE_ENABLED, (!isAudioEnabled).toString()); + const newIsAudioEnabled = !isAudioEnabled; + + publisherRef.current.publishAudio(newIsAudioEnabled); + setIsAudioEnabled(newIsAudioEnabled); + setStorageItem(STORAGE_KEYS.AUDIO_SOURCE_ENABLED, newIsAudioEnabled.toString()); setIsForceMuted(false); }; @@ -342,6 +337,7 @@ const usePublisher = (): PublisherContextType => { toggleVideo, changeBackground, unpublish, + publisherOptions, }; }; export default usePublisher; diff --git a/frontend/src/Context/PublisherProvider/usePublisherOptions/usePublisherOptions.spec.tsx b/frontend/src/Context/PublisherProvider/usePublisherOptions/usePublisherOptions.spec.tsx index 91c6e53a9..d55405dc7 100644 --- a/frontend/src/Context/PublisherProvider/usePublisherOptions/usePublisherOptions.spec.tsx +++ b/frontend/src/Context/PublisherProvider/usePublisherOptions/usePublisherOptions.spec.tsx @@ -1,11 +1,13 @@ import { describe, expect, it, vi, beforeEach, afterAll } from 'vitest'; -import { renderHook as renderHookBase, waitFor } from '@testing-library/react'; +import { act, 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 { AppConfigProviderWrapperOptions, makeAppConfigProviderWrapper } from '@test/providers'; +import Suspense$ from '@Context/Suspense$/SuspenseContext'; +import composeProviders from '@utils/composeProviders'; import usePublisherOptions from './usePublisherOptions'; import { UserContextType } from '../../user'; @@ -25,26 +27,17 @@ const customSettings = { publishAudio: true, publishVideo: true, name: 'Foo Bar', - backgroundFilter: { - type: 'backgroundBlur', - blurStrength: 'high', - }, + backgroundFilter: { type: 'backgroundBlur', blurStrength: 'high' }, noiseSuppression: false, audioSource: '68f1d1e6f11c629b1febe51a95f8f740f8ac5cd3d4c91419bd2b52bb1a9a01cd', videoSource: 'a68ec4e4a6bc10dc572bd806414b0da27d0aefb0ad822f7ba4cf9b226bb9b7c2', publishCaptions: true, }; -const mockUserContextWithDefaultSettings = { - user: { - defaultSettings, - }, -} as UserContextType; +const mockUserContextWithDefaultSettings = { user: { defaultSettings } } as UserContextType; const mockUserContextWithCustomSettings = { - user: { - defaultSettings: customSettings, - }, + user: { defaultSettings: customSettings }, } as UserContextType; describe('usePublisherOptions', () => { @@ -53,15 +46,8 @@ describe('usePublisherOptions', () => { beforeEach(async () => { enumerateDevicesMock = vi.fn(); - vi.stubGlobal('navigator', { - mediaDevices: { - enumerateDevices: enumerateDevicesMock, - }, - }); - Object.defineProperty(window, 'localStorage', { - value: localStorageMock, - writable: true, - }); + vi.stubGlobal('navigator', { mediaDevices: { enumerateDevices: enumerateDevicesMock } }); + Object.defineProperty(window, 'localStorage', { value: localStorageMock, writable: true }); deviceStore = new DeviceStore(); enumerateDevicesMock.mockResolvedValue([]); await deviceStore.init(); @@ -75,21 +61,19 @@ describe('usePublisherOptions', () => { it('should use default settings', async () => { vi.spyOn(OT, 'hasMediaProcessorSupport').mockReturnValue(true); vi.mocked(useUserContext).mockImplementation(() => mockUserContextWithDefaultSettings); - const { result } = renderHook(() => usePublisherOptions()); + const { result } = await renderHook(() => + usePublisherOptions({ isVideoEnabled: false, isAudioEnabled: false }) + ); await waitFor(() => { expect(result.current).toEqual({ resolution: '1280x720', publishAudio: false, publishVideo: false, audioSource: undefined, - videoSource: undefined, + videoSource: null, insertDefaultUI: false, - audioFallback: { - publisher: true, - }, - audioFilter: { - type: 'advancedNoiseSuppression', - }, + audioFallback: { publisher: true }, + audioFilter: undefined, // no audio source, so no noise suppression videoFilter: undefined, name: '', initials: '', @@ -101,7 +85,9 @@ describe('usePublisherOptions', () => { it('should not have advanced noise suppression if not supported by browser', async () => { vi.spyOn(OT, 'hasMediaProcessorSupport').mockReturnValue(false); vi.mocked(useUserContext).mockImplementation(() => mockUserContextWithDefaultSettings); - const { result } = renderHook(() => usePublisherOptions()); + const { result } = await renderHook(() => + usePublisherOptions({ isVideoEnabled: true, isAudioEnabled: true }) + ); await waitFor(() => { expect(result.current?.audioFilter).toBe(undefined); @@ -118,7 +104,9 @@ describe('usePublisherOptions', () => { ]); await deviceStore.init(); vi.mocked(useUserContext).mockImplementation(() => mockUserContextWithCustomSettings); - const { result } = renderHook(() => usePublisherOptions()); + const { result } = await renderHook(() => + usePublisherOptions({ isVideoEnabled: true, isAudioEnabled: true }) + ); await waitFor(() => { expect(result.current).toEqual({ resolution: '1280x720', @@ -127,14 +115,9 @@ describe('usePublisherOptions', () => { audioSource: '68f1d1e6f11c629b1febe51a95f8f740f8ac5cd3d4c91419bd2b52bb1a9a01cd', videoSource: 'a68ec4e4a6bc10dc572bd806414b0da27d0aefb0ad822f7ba4cf9b226bb9b7c2', insertDefaultUI: false, - audioFallback: { - publisher: true, - }, + audioFallback: { publisher: true }, audioFilter: undefined, - videoFilter: { - type: 'backgroundBlur', - blurStrength: 'high', - }, + videoFilter: { type: 'backgroundBlur', blurStrength: 'high' }, name: 'Foo Bar', initials: 'FB', publishCaptions: true, @@ -144,15 +127,10 @@ describe('usePublisherOptions', () => { describe('configurable features', () => { it('should disable audio publishing when allowAudioOnJoin is false', async () => { - const { result } = renderHook(() => usePublisherOptions(), { - appConfigOptions: { - value: { - audioSettings: { - allowAudioOnJoin: false, - }, - }, - }, - }); + const { result } = await renderHook( + () => usePublisherOptions({ isVideoEnabled: true, isAudioEnabled: true }), + { appConfigOptions: { value: { audioSettings: { allowAudioOnJoin: false } } } } + ); await waitFor(() => { expect(result.current?.publishAudio).toBe(false); @@ -160,60 +138,44 @@ describe('usePublisherOptions', () => { }); it('should disable video publishing when allowVideoOnJoin is false', async () => { - const { result } = renderHook(() => usePublisherOptions(), { - appConfigOptions: { - value: { - audioSettings: { - allowAudioOnJoin: false, + const { result } = await renderHook( + () => usePublisherOptions({ isVideoEnabled: true, isAudioEnabled: true }), + { + appConfigOptions: { + value: { + videoSettings: { allowVideoOnJoin: false }, + audioSettings: { allowAudioOnJoin: false }, }, }, - }, - }); + } + ); await waitFor(() => { expect(result.current?.publishVideo).toBe(false); + expect(result.current?.publishAudio).toBe(false); }); }); it('should configure resolution from config', async () => { - const { result } = renderHook(() => usePublisherOptions(), { - appConfigOptions: { - value: { - videoSettings: { - defaultResolution: '640x480', - }, - }, - }, - }); + const { result } = await renderHook( + () => usePublisherOptions({ isVideoEnabled: true, isAudioEnabled: true }), + { appConfigOptions: { value: { videoSettings: { defaultResolution: '640x480' } } } } + ); await waitFor(() => { expect(result.current?.resolution).toBe('640x480'); }); }); }); - - it('should disable audio and video from storage options', async () => { - vi.spyOn(OT, 'hasMediaProcessorSupport').mockReturnValue(true); - setStorageItem(STORAGE_KEYS.AUDIO_SOURCE_ENABLED, 'false'); - setStorageItem(STORAGE_KEYS.VIDEO_SOURCE_ENABLED, 'true'); - - await deviceStore.init(); - vi.mocked(useUserContext).mockImplementation(() => mockUserContextWithCustomSettings); - const { result } = renderHook(() => usePublisherOptions()); - await waitFor(() => { - expect(result.current?.publishAudio).toBe(false); - expect(result.current?.publishVideo).toBe(true); - }); - }); }); function renderHook( render: (initialProps: Props) => Result, - options?: { - appConfigOptions?: AppConfigProviderWrapperOptions; - } + options?: { appConfigOptions?: AppConfigProviderWrapperOptions } ) { const { AppConfigWrapper } = makeAppConfigProviderWrapper(options?.appConfigOptions); - return renderHookBase(render, { ...options, wrapper: AppConfigWrapper }); + const wrapper = composeProviders(Suspense$, AppConfigWrapper); + + return act(() => renderHookBase(render, { ...options, wrapper })); } diff --git a/frontend/src/Context/PublisherProvider/usePublisherOptions/usePublisherOptions.tsx b/frontend/src/Context/PublisherProvider/usePublisherOptions/usePublisherOptions.tsx index 9be295c09..465d7ce02 100644 --- a/frontend/src/Context/PublisherProvider/usePublisherOptions/usePublisherOptions.tsx +++ b/frontend/src/Context/PublisherProvider/usePublisherOptions/usePublisherOptions.tsx @@ -9,14 +9,23 @@ import useAppConfig from '@Context/AppConfig/hooks/useAppConfig'; import useUserContext from '@hooks/useUserContext'; import getInitials from '@utils/getInitials'; import DeviceStore from '@utils/DeviceStore'; -import { getStorageItem, STORAGE_KEYS } from '@utils/storage'; +import useIsCameraControlAllowed from '@Context/AppConfig/hooks/useIsCameraControlAllowed'; +import useIsMicrophoneControlAllowed from '@Context/AppConfig/hooks/useIsMicrophoneControlAllowed'; +import useSuspenseUntilAppConfigReady from '@Context/AppConfig/hooks/useSuspenseUntilAppConfigReady'; + +type UsePublisherOptionsProps = { isVideoEnabled: boolean; isAudioEnabled: boolean }; /** * React hook to get PublisherProperties combining default options and options set in UserContext * @returns {PublisherProperties | null} publisher properties object */ -const usePublisherOptions = (): PublisherProperties | null => { +const usePublisherOptions = ({ + isVideoEnabled, + isAudioEnabled, +}: UsePublisherOptionsProps): PublisherProperties | null => { + useSuspenseUntilAppConfigReady(); + const { user } = useUserContext(); const defaultResolution = useAppConfig(({ videoSettings }) => videoSettings.defaultResolution); @@ -24,8 +33,15 @@ const usePublisherOptions = (): PublisherProperties | null => { const allowAudioOnJoin = useAppConfig(({ audioSettings }) => audioSettings.allowAudioOnJoin); const [publisherOptions, setPublisherOptions] = useState(null); + const deviceStoreRef = useRef(null); + const isCameraAllowed = useIsCameraControlAllowed(); + const isMicrophoneAllowed = useIsMicrophoneControlAllowed(); + + const shouldInitializeAudioSource = isMicrophoneAllowed && isAudioEnabled; + const shouldInitializeVideoSource = isCameraAllowed && isVideoEnabled; + useEffect(() => { const setOptions = async () => { if (!deviceStoreRef.current) { @@ -33,29 +49,35 @@ const usePublisherOptions = (): PublisherProperties | null => { await deviceStoreRef.current.init(); } - const videoSource = deviceStoreRef.current.getConnectedDeviceId('videoinput'); - const audioSource = deviceStoreRef.current.getConnectedDeviceId('audioinput'); + const videoSource = shouldInitializeVideoSource + ? deviceStoreRef.current.getConnectedDeviceId('videoinput') + : null; + + const audioSource = shouldInitializeAudioSource + ? deviceStoreRef.current.getConnectedDeviceId('audioinput') + : undefined; + + const { name, noiseSuppression, backgroundFilter, publishCaptions } = user.defaultSettings; - const { - name, - noiseSuppression, - backgroundFilter, - publishAudio, - publishVideo, - publishCaptions, - } = user.defaultSettings; const initials = getInitials(name); - const audioFilter: AudioFilter | undefined = - noiseSuppression && hasMediaProcessorSupport() + const audioFilter: AudioFilter | undefined = (() => { + if (!shouldInitializeAudioSource) { + return undefined; + } + + return noiseSuppression && hasMediaProcessorSupport() ? { type: 'advancedNoiseSuppression' } : undefined; + })(); - const videoFilter: VideoFilter | undefined = - backgroundFilter && hasMediaProcessorSupport() ? backgroundFilter : undefined; + const videoFilter: VideoFilter | undefined = (() => { + if (!shouldInitializeVideoSource) { + return undefined; + } - const isAudioDisabled = getStorageItem(STORAGE_KEYS.AUDIO_SOURCE_ENABLED) === 'false'; - const isVideoDisabled = getStorageItem(STORAGE_KEYS.VIDEO_SOURCE_ENABLED) === 'false'; + return backgroundFilter && hasMediaProcessorSupport() ? backgroundFilter : undefined; + })(); setPublisherOptions({ audioFallback: { publisher: true }, @@ -63,8 +85,8 @@ const usePublisherOptions = (): PublisherProperties | null => { initials, insertDefaultUI: false, name, - publishAudio: allowAudioOnJoin && publishAudio && !isAudioDisabled, - publishVideo: allowVideoOnJoin && publishVideo && !isVideoDisabled, + publishAudio: shouldInitializeAudioSource && allowAudioOnJoin, + publishVideo: shouldInitializeVideoSource && allowVideoOnJoin, resolution: defaultResolution, audioFilter, videoFilter, @@ -74,7 +96,14 @@ const usePublisherOptions = (): PublisherProperties | null => { }; setOptions(); - }, [allowAudioOnJoin, defaultResolution, allowVideoOnJoin, user.defaultSettings]); + }, [ + allowAudioOnJoin, + allowVideoOnJoin, + defaultResolution, + shouldInitializeAudioSource, + shouldInitializeVideoSource, + user.defaultSettings, + ]); return publisherOptions; }; diff --git a/frontend/src/Context/PublisherProvider/usePublisherQuality/usePublisherQuality.tsx b/frontend/src/Context/PublisherProvider/usePublisherQuality/usePublisherQuality.tsx index af0335037..2f2c953f6 100644 --- a/frontend/src/Context/PublisherProvider/usePublisherQuality/usePublisherQuality.tsx +++ b/frontend/src/Context/PublisherProvider/usePublisherQuality/usePublisherQuality.tsx @@ -18,7 +18,7 @@ const usePublisherQuality = (publisher: Publisher | null): NetworkQuality => { const handleVideoDisabled = useCallback(() => { setQuality('bad'); - // eslint-disable-next-line react-hooks/immutability + user.issues.audioFallbacks += 1; }, [user]); diff --git a/frontend/src/Context/RoomContext.tsx b/frontend/src/Context/RoomContext.tsx index a7850863d..f63b3f8bd 100644 --- a/frontend/src/Context/RoomContext.tsx +++ b/frontend/src/Context/RoomContext.tsx @@ -1,44 +1,20 @@ -import { Outlet } from 'react-router-dom'; -import { ReactElement } from 'react'; -import RedirectToUnsupportedBrowserPage from '../components/RedirectToUnsupportedBrowserPage'; +import React, { PropsWithChildren } from 'react'; import { AudioOutputProvider } from './AudioOutputProvider'; -import UserProvider from './user'; -import appConfig from './AppConfig'; import { BackgroundPublisherProvider } from './BackgroundPublisherProvider'; -import type { AppConfig, AppConfigApi } from './AppConfig'; +import SessionProvider from './SessionProvider/session'; /** - * 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. + * @description RoomContext - Wrapper for all of the contexts used by the waiting room and the meeting room. + * @param {PropsWithChildren} props - The props for the RoomContext component. + * @property {ReactNode} props.children - The content to be rendered within the RoomContext. + * @returns {React.FC} A React functional component that provides the RoomContext. */ -const RoomContext = ({ appConfigValue }: { appConfigValue?: AppConfig }): ReactElement => ( - - - - - - - - - - - +const RoomContext: React.FC = ({ children }) => ( + + + {children} + + ); -/** - * 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/Suspense$/SuspenseContext.tsx b/frontend/src/Context/Suspense$/SuspenseContext.tsx new file mode 100644 index 000000000..b1cddd798 --- /dev/null +++ b/frontend/src/Context/Suspense$/SuspenseContext.tsx @@ -0,0 +1,20 @@ +import React, { createContext, Suspense } from 'react'; +import suspenseToken from './helpers/suspenseToken'; + +export const suspenseContext = createContext(null); + +/** + * React `use` is not context-aware, which means that you can use it outside Suspense boundaries. + * This could make the application crash silently at runtime. To prevent this, we will use Suspense$ and use$ instead. + * + * Suspense$ provides context, and use$ will throw if used outside Suspense$ boundaries. + */ +const Suspense$: React.FC[0]> = ({ children, ...props }) => { + return ( + + {children} + + ); +}; + +export default Suspense$; diff --git a/frontend/src/Context/Suspense$/helpers/suspenseToken.ts b/frontend/src/Context/Suspense$/helpers/suspenseToken.ts new file mode 100644 index 000000000..4160455bb --- /dev/null +++ b/frontend/src/Context/Suspense$/helpers/suspenseToken.ts @@ -0,0 +1,3 @@ +const suspenseToken = Symbol('suspense$'); + +export default suspenseToken; diff --git a/frontend/src/Context/Suspense$/hooks/use$.ts b/frontend/src/Context/Suspense$/hooks/use$.ts new file mode 100644 index 000000000..58a53ba7c --- /dev/null +++ b/frontend/src/Context/Suspense$/hooks/use$.ts @@ -0,0 +1,36 @@ +/* eslint-disable react-hooks/rules-of-hooks */ +import { use, useContext } from 'react'; +import { suspenseContext } from '../SuspenseContext'; +import suspenseToken from '../helpers/suspenseToken'; +import isPromise from '@utils/isPromise'; + +/** + * Context-aware wrapper for React's use function. + * Ensures that the hook is used within a Suspense$ Provider. + * @param usable - A promise, context + * @returns The usable resolved value. + */ +const use$ = (...[usable]: Parameters>): T => { + const token = useContext(suspenseContext); + const isSafelyWrapped = token === suspenseToken; + + if (!isSafelyWrapped) { + throw new Error('use$ must be used within a Suspense$ Provider'); + } + + /** + * Some of our implementations could call use with nonPromise/nonContext values. + * This validation allow us to avoid suspending in those cases. + */ + if (isPromise(usable) || isContext(usable)) { + return use(usable); + } + + return usable as T; +}; + +function isContext(value: unknown): value is React.Context { + return Boolean(value && typeof value === 'object' && 'Provider' in value); +} + +export default use$; diff --git a/frontend/src/Context/Suspense$/index.ts b/frontend/src/Context/Suspense$/index.ts new file mode 100644 index 000000000..9fc5daf51 --- /dev/null +++ b/frontend/src/Context/Suspense$/index.ts @@ -0,0 +1,2 @@ +export { default } from './SuspenseContext'; +export { default as use$ } from './hooks/use$'; diff --git a/frontend/src/Context/tests/RoomContext.spec.tsx b/frontend/src/Context/tests/RoomContext.spec.tsx index a11c01c4d..fce15c4f1 100644 --- a/frontend/src/Context/tests/RoomContext.spec.tsx +++ b/frontend/src/Context/tests/RoomContext.spec.tsx @@ -1,11 +1,13 @@ -import { render, screen } from '@testing-library/react'; +import { render as renderBase, screen } from '@testing-library/react'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { PropsWithChildren } from 'react'; +import { act, PropsWithChildren, ReactElement } 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 Suspense$ from '@Context/Suspense$'; +import { makeAppConfigProviderWrapper } from '@test/providers'; +import composeProviders from '@utils/composeProviders'; import RoomContext from '../RoomContext'; import { UserContextType } from '../user'; import { AudioOutputContextType } from '../AudioOutputProvider'; @@ -21,23 +23,12 @@ const fakeName = 'Tommy Traddles'; const fakeAudioOutput = 'their-device-id'; const mockUserContextWithDefaultSettings = { - user: { - defaultSettings: { - name: fakeName, - }, - }, + user: { defaultSettings: { name: fakeName } }, } as UserContextType; 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(() => { @@ -66,14 +57,21 @@ describe('RoomContext', () => { }); }); - it('renders content', () => { + it('renders content', async () => { const TestComponent = () =>
Test Component
; - render( + await render( - }> - } /> + + + + + } + /> @@ -82,7 +80,7 @@ describe('RoomContext', () => { expect(screen.getByTestId('test-component')).toBeInTheDocument(); }); - it('provides context values to child components', () => { + it('provides context values to child components', async () => { const TestComponent = () => { const { user } = useUserContext(); const { currentAudioOutputDevice } = useAudioOutputContext(); @@ -95,11 +93,18 @@ describe('RoomContext', () => { ); }; - render( + await render( - }> - } /> + + + + + } + /> @@ -109,3 +114,18 @@ describe('RoomContext', () => { expect(screen.getByTestId('audio-output').textContent).toBe(fakeAudioOutput); }); }); + +async function render(ui: ReactElement) { + const { AppConfigWrapper } = makeAppConfigProviderWrapper(); + + const composeWrapper = composeProviders(Suspense$, AppConfigWrapper); + + let result: ReturnType; + + await act(() => { + result = renderBase(ui, { wrapper: composeWrapper }); + return Promise.resolve(); + }); + + return result!; +} diff --git a/frontend/src/Context/user.tsx b/frontend/src/Context/user.tsx index d48ec8405..25a487b7a 100644 --- a/frontend/src/Context/user.tsx +++ b/frontend/src/Context/user.tsx @@ -39,10 +39,7 @@ export type UserType = { // Create the User context with an initial value of null export const UserContext = createContext(null); -export type UserProviderProps = { - children: ReactNode; - value?: UserType; -}; +export type UserProviderProps = { children: ReactNode; value?: UserType }; /** * UserProvider component to wrap the application and provide the User preferences to be used by the publisher. @@ -74,13 +71,7 @@ const UserProvider = ({ children, value: initialUserState }: UserProviderProps): } ); - const value = useMemo( - () => ({ - user, - setUser, - }), - [user] - ); + const value = useMemo(() => ({ user, setUser }), [user]); // Provide the User context to child components return {children}; diff --git a/frontend/src/components/BackgroundEffects/BackgroundVideoContainer/BackgroundVideoContainer.tsx b/frontend/src/components/BackgroundEffects/BackgroundVideoContainer/BackgroundVideoContainer.tsx index 95a4acad7..2acda3507 100644 --- a/frontend/src/components/BackgroundEffects/BackgroundVideoContainer/BackgroundVideoContainer.tsx +++ b/frontend/src/components/BackgroundEffects/BackgroundVideoContainer/BackgroundVideoContainer.tsx @@ -36,9 +36,9 @@ const BackgroundVideoContainer = ({ containerRef.current.appendChild(publisherVideoElement); const myVideoElement = publisherVideoElement as HTMLElement; myVideoElement.classList.add('video__element'); - // eslint-disable-next-line react-hooks/immutability + myVideoElement.title = 'publisher-preview'; - // eslint-disable-next-line react-hooks/immutability + myVideoElement.style.borderRadius = '12px'; myVideoElement.style.maxHeight = isTabletViewport ? '80%' : '450px'; diff --git a/frontend/src/components/Banner/Banner.tsx b/frontend/src/components/Banner/Banner.tsx index 6d051dcd8..80a1b20f0 100644 --- a/frontend/src/components/Banner/Banner.tsx +++ b/frontend/src/components/Banner/Banner.tsx @@ -1,5 +1,5 @@ import { ReactElement } from 'react'; -import { AppBar, Toolbar } from '@mui/material'; +import { AppBar, AppBarProps, Toolbar } from '@mui/material'; import Box from '@ui/Box'; import Stack from '@ui/Stack'; import useCustomTheme from '@Context/Theme'; @@ -7,18 +7,23 @@ import BannerDateTime from '../BannerDateTime'; import BannerLinks from '../BannerLinks'; import BannerLogo from '../BannerLogo'; import BannerLanguage from '../BannerLanguage'; +import classNames from 'classnames'; + +type BannerProps = AppBarProps; /** * Banner Component * * This component returns a banner that includes a logo, current date/time, language selector, and some links. + * @param root0 - Props for the Banner component. + * @param {string} root0.className - Additional CSS class names to apply to the banner container. * @returns {ReactElement} - the banner component. */ -const Banner = (): ReactElement => { +const Banner: React.FC = ({ className, ...props }): ReactElement => { const theme = useCustomTheme(); return ( - + diff --git a/frontend/src/components/LanguageSelector/LanguageSelector.spec.tsx b/frontend/src/components/LanguageSelector/LanguageSelector.spec.tsx index 7edcc9fe9..378180663 100644 --- a/frontend/src/components/LanguageSelector/LanguageSelector.spec.tsx +++ b/frontend/src/components/LanguageSelector/LanguageSelector.spec.tsx @@ -4,10 +4,7 @@ vi.mock('../../env', async () => { const actual = await vi.importActual('../../env'); const { Env } = actual; - return { - ...actual, - default: new Env({}), - }; + return { ...actual, default: new Env({}) }; }); import { render, screen, fireEvent, waitFor } from '@testing-library/react'; @@ -38,23 +35,10 @@ const mockT = vi.fn((key: string) => { const mockI18n: { language: string | undefined | null; changeLanguage: typeof mockChangeLanguage; - options: { - fallbackLng: string; - }; -} = { - language: 'en', - changeLanguage: mockChangeLanguage, - options: { - fallbackLng: 'en', - }, -}; - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: mockT, - i18n: mockI18n, - }), -})); + options: { fallbackLng: string }; +} = { language: 'en', changeLanguage: mockChangeLanguage, options: { fallbackLng: 'en' } }; + +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: mockT, i18n: mockI18n }) })); describe('LanguageSelector', () => { beforeEach(() => { diff --git a/frontend/src/components/MeetingRoom/ArchivingButton/ArchivingButton.tsx b/frontend/src/components/MeetingRoom/ArchivingButton/ArchivingButton.tsx index 82d4e391e..403e02c2f 100644 --- a/frontend/src/components/MeetingRoom/ArchivingButton/ArchivingButton.tsx +++ b/frontend/src/components/MeetingRoom/ArchivingButton/ArchivingButton.tsx @@ -9,10 +9,7 @@ import useAppConfig from '@Context/AppConfig/hooks/useAppConfig'; import ToolbarButton from '../ToolbarButton'; import PopupDialog, { DialogTexts } from '../PopupDialog'; -export type ArchivingButtonProps = { - isOverflowButton?: boolean; - handleClick?: () => void; -}; +export type ArchivingButtonProps = { isOverflowButton?: boolean; handleClick?: () => void }; /** * ArchivingButton Component @@ -73,7 +70,7 @@ const ArchivingButton = ({ try { await startArchiving(roomName); } catch (err) { - console.log(err); + console.error(err); } } } else if (archiveId && roomName) { @@ -98,9 +95,7 @@ const ArchivingButton = ({ style={{ color: `${isRecording ? 'rgb(239 68 68)' : 'white'}` }} /> } - sx={{ - marginTop: isOverflowButton ? '0px' : '4px', - }} + sx={{ marginTop: isOverflowButton ? '0px' : '4px' }} isOverflowButton={isOverflowButton} /> diff --git a/frontend/src/components/MeetingRoom/SmallViewportHeader/SmallViewportHeader.tsx b/frontend/src/components/MeetingRoom/SmallViewportHeader/SmallViewportHeader.tsx index b2806fd91..6cbd0ed2b 100644 --- a/frontend/src/components/MeetingRoom/SmallViewportHeader/SmallViewportHeader.tsx +++ b/frontend/src/components/MeetingRoom/SmallViewportHeader/SmallViewportHeader.tsx @@ -1,20 +1,25 @@ -import { ReactElement, useState } from 'react'; +import React, { ComponentProps, useState } from 'react'; import RadioButtonCheckedIcon from '@mui/icons-material/RadioButtonChecked'; import { ContentCopy } from '@mui/icons-material'; import { IconButton, Fade } from '@mui/material'; import CheckIcon from '@mui/icons-material/Check'; -import useSessionContext from '../../../hooks/useSessionContext'; -import useRoomName from '../../../hooks/useRoomName'; -import useRoomShareUrl from '../../../hooks/useRoomShareUrl'; +import classNames from 'classnames'; +import useSessionContext from '@hooks/useSessionContext'; +import useRoomName from '@hooks/useRoomName'; +import useRoomShareUrl from '@hooks/useRoomShareUrl'; + +type SmallViewportHeaderProps = ComponentProps<'div'>; /** * SmallViewportHeader Component * * This component shows a header bar in smaller viewport devices that consists of recording on/off indicator, * meeting room name, and copy-to-clipboard button. + * @param root0 + * @param root0.className * @returns {ReactElement} The small viewport header component. */ -const SmallViewportHeader = (): ReactElement => { +const SmallViewportHeader: React.FC = ({ className, ...props }) => { const { archiveId } = useSessionContext(); const isRecording = !!archiveId; const roomName = useRoomName(); @@ -32,8 +37,12 @@ const SmallViewportHeader = (): ReactElement => { }; return (
{isRecording && } diff --git a/frontend/src/components/MeetingRoom/Toolbar/Toolbar.tsx b/frontend/src/components/MeetingRoom/Toolbar/Toolbar.tsx index 5a8e4bc47..2bb604051 100644 --- a/frontend/src/components/MeetingRoom/Toolbar/Toolbar.tsx +++ b/frontend/src/components/MeetingRoom/Toolbar/Toolbar.tsx @@ -4,6 +4,7 @@ import { RightPanelActiveTab } from '@hooks/useRightPanel'; import isReportIssueEnabled from '@utils/isReportIssueEnabled'; import useToolbarButtons from '@hooks/useToolbarButtons'; import useBackgroundPublisherContext from '@hooks/useBackgroundPublisherContext'; +import classNames from 'classnames'; import ScreenSharingButton from '../../ScreenSharingButton'; import TimeRoomNameMeetingRoom from '../TimeRoomName'; import ExitButton from '../ExitButton'; @@ -162,10 +163,13 @@ const Toolbar = ({ ref={toolbarRef} className="absolute bottom-0 left-0 flex h-[80px] w-full flex-row items-center justify-between bg-darkGray-100 p-4" > -
+
{displayTimeRoomName && }
-
+
-
+
{rightPanelButtons}
diff --git a/frontend/src/components/MeetingRoom/ZoomIndicator/ZoomIndicator.tsx b/frontend/src/components/MeetingRoom/ZoomIndicator/ZoomIndicator.tsx index f650f1b08..9b2c18629 100644 --- a/frontend/src/components/MeetingRoom/ZoomIndicator/ZoomIndicator.tsx +++ b/frontend/src/components/MeetingRoom/ZoomIndicator/ZoomIndicator.tsx @@ -84,9 +84,7 @@ const ZoomIndicator = ({ sx={{ padding: '4px', color: 'white', - '&:disabled': { - color: 'rgba(255, 255, 255, 0.3)', - }, + '&:disabled': { color: 'rgba(255, 255, 255, 0.3)' }, }} > @@ -102,9 +100,7 @@ const ZoomIndicator = ({ sx={{ padding: '4px', color: 'white', - '&:disabled': { - color: 'rgba(255, 255, 255, 0.3)', - }, + '&:disabled': { color: 'rgba(255, 255, 255, 0.3)' }, }} > diff --git a/frontend/src/components/Publisher/Publisher.tsx b/frontend/src/components/Publisher/Publisher.tsx index 9cdf60222..4943ce4f0 100644 --- a/frontend/src/components/Publisher/Publisher.tsx +++ b/frontend/src/components/Publisher/Publisher.tsx @@ -1,5 +1,7 @@ import { ReactElement, useEffect, useRef } from 'react'; import { Box } from 'opentok-layout-js'; +import useIsCameraControlAllowed from '@Context/AppConfig/hooks/useIsCameraControlAllowed'; +import useIsMicrophoneControlAllowed from '@Context/AppConfig/hooks/useIsMicrophoneControlAllowed'; import usePublisherContext from '../../hooks/usePublisherContext'; import VoiceIndicatorIcon from '../MeetingRoom/VoiceIndicator'; import useAudioLevels from '../../hooks/useAudioLevels'; @@ -22,15 +24,21 @@ export type PublisherProps = { * @returns {ReactElement} The publisher component. */ const Publisher = ({ box }: PublisherProps): ReactElement => { + const isCameraControlAllowed = useIsCameraControlAllowed(); + const isMicrophoneControlAllowed = useIsMicrophoneControlAllowed(); + const { publisherVideoElement: element, isVideoEnabled, publisher, isAudioEnabled, } = usePublisherContext(); + const audioLevel = useAudioLevels(); + // We store this in a ref to get a reference to the div so that we can append a video to it const pubContainerRef = useRef(null); + useEffect(() => { if (element && pubContainerRef.current) { element.classList.add( @@ -52,16 +60,19 @@ const Publisher = ({ box }: PublisherProps): ReactElement => { const audioIndicatorStyle = 'rounded-xl absolute top-3 right-3 bg-darkGray-55 h-6 w-6 items-center justify-center flex m-auto'; + const shouldShowAvatarInitials = !(isCameraControlAllowed && isVideoEnabled); + const shouldShowVoiceIndicator = !(isMicrophoneControlAllowed && isAudioEnabled); + return ( - {!isVideoEnabled && ( + {shouldShowAvatarInitials && ( { username={username} /> )} - {isAudioEnabled ? ( + + {shouldShowVoiceIndicator && ( - ) : ( + )} + + {!shouldShowVoiceIndicator && ( )} + ); diff --git a/frontend/src/components/RedirectToUnsupportedBrowserPage/RedirectToUnsupportedBrowserPage.spec.tsx b/frontend/src/components/RedirectToUnsupportedBrowserPage/RedirectToUnsupportedBrowserPage.spec.tsx index ab86c549f..a29aea977 100644 --- a/frontend/src/components/RedirectToUnsupportedBrowserPage/RedirectToUnsupportedBrowserPage.spec.tsx +++ b/frontend/src/components/RedirectToUnsupportedBrowserPage/RedirectToUnsupportedBrowserPage.spec.tsx @@ -1,22 +1,16 @@ -import { describe, it, expect, vi, Mock, afterEach } from 'vitest'; +import { describe, it, expect, vi, Mock } from 'vitest'; import { checkSystemRequirements } from '@vonage/client-sdk-video'; import { render } from '@testing-library/react'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; import RedirectToUnsupportedBrowserPage from './RedirectToUnsupportedBrowserPage'; -vi.mock('@vonage/client-sdk-video', () => ({ - checkSystemRequirements: vi.fn(), -})); +vi.mock('@vonage/client-sdk-video', () => ({ checkSystemRequirements: vi.fn() })); describe('RedirectToUnsupportedBrowserPage', () => { const supportedText = 'You have arrived'; const unsupportedText = 'Your browser is unsupported'; const TestComponent = () =>
{supportedText}
; - afterEach(() => { - vi.clearAllMocks(); - }); - it('for unsupported browsers, redirects to the unsupported browser page', () => { // Mocking an unsupported browser (checkSystemRequirements as Mock).mockReturnValue(0); @@ -25,14 +19,10 @@ describe('RedirectToUnsupportedBrowserPage', () => { {unsupportedText}
} /> - - - - } - /> + + }> + } /> + ); @@ -48,14 +38,9 @@ describe('RedirectToUnsupportedBrowserPage', () => { {unsupportedText}
} /> - - - - } - /> + }> + } /> + ); diff --git a/frontend/src/components/RedirectToUnsupportedBrowserPage/RedirectToUnsupportedBrowserPage.tsx b/frontend/src/components/RedirectToUnsupportedBrowserPage/RedirectToUnsupportedBrowserPage.tsx index db61f780a..0ef79d301 100644 --- a/frontend/src/components/RedirectToUnsupportedBrowserPage/RedirectToUnsupportedBrowserPage.tsx +++ b/frontend/src/components/RedirectToUnsupportedBrowserPage/RedirectToUnsupportedBrowserPage.tsx @@ -1,31 +1,17 @@ import { ReactElement } from 'react'; -import { Navigate } from 'react-router-dom'; +import { Navigate, Outlet } from 'react-router-dom'; import { checkSystemRequirements } from '@vonage/client-sdk-video'; -export type RedirectToUnsupportedBrowserPageProps = { - children: ReactElement; -}; - /** * This component checks whether the user's browser is supported by the Vonage Video API. * If the browser is unsupported, users are redirected to the unsupported browser page. * @param {RedirectToUnsupportedBrowserPageProps} props - The props for this component. * @returns {ReactElement} - The redirect to unsupported browser page component. */ -const RedirectToUnsupportedBrowserPage = ({ - children, -}: RedirectToUnsupportedBrowserPageProps): ReactElement => { +const RedirectToUnsupportedBrowserPage = (): ReactElement => { const isSupportedBrowser = checkSystemRequirements() === 1; - return isSupportedBrowser ? ( - children - ) : ( - - ); + return isSupportedBrowser ? : ; }; export default RedirectToUnsupportedBrowserPage; diff --git a/frontend/src/components/RedirectToWaitingRoom/RedirectToWaitingRoom.spec.tsx b/frontend/src/components/RedirectToWaitingRoom/RedirectToWaitingRoom.spec.tsx index 7d831ed10..d4f584e6c 100644 --- a/frontend/src/components/RedirectToWaitingRoom/RedirectToWaitingRoom.spec.tsx +++ b/frontend/src/components/RedirectToWaitingRoom/RedirectToWaitingRoom.spec.tsx @@ -4,28 +4,22 @@ vi.mock('../../env', async () => { const actual = await vi.importActual('../../env'); const { Env } = actual; - return { - ...actual, - default: new Env({}), - }; + return { ...actual, default: new Env({}) }; }); -import { render } from '@testing-library/react'; -import { MemoryRouter, Route, Routes } from 'react-router-dom'; -import { ReactElement } from 'react'; -import env from '../../env'; -import RedirectToWaitingRoom from './RedirectToWaitingRoom'; - const mockedRoomName = { roomName: 'test-room-name' }; vi.mock('react-router-dom', async () => { const mod = await vi.importActual('react-router-dom'); - return { - ...mod, - useParams: () => mockedRoomName, - }; + return { ...mod, useParams: () => mockedRoomName }; }); +import { render } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { ReactElement } from 'react'; +import env from '../../env'; +import RedirectToWaitingRoom from './RedirectToWaitingRoom'; + describe('RedirectToWaitingRoom Component', () => { const TestComponent = (): ReactElement =>
TestComponent
; diff --git a/frontend/src/components/SoundTest/SoundTest.tsx b/frontend/src/components/SoundTest/SoundTest.tsx index 120f0075c..5ce3c49c9 100644 --- a/frontend/src/components/SoundTest/SoundTest.tsx +++ b/frontend/src/components/SoundTest/SoundTest.tsx @@ -23,7 +23,7 @@ const SoundTest = ({ children }: SoundTestProps): ReactElement => { const stopAudio = useCallback(() => { audioElement.pause(); - // eslint-disable-next-line react-hooks/immutability + audioElement.currentTime = 0; setAudioIsPlaying(false); }, [audioElement]); diff --git a/frontend/src/components/Subscriber/Subscriber.tsx b/frontend/src/components/Subscriber/Subscriber.tsx index a4a249f3c..36a4c5852 100644 --- a/frontend/src/components/Subscriber/Subscriber.tsx +++ b/frontend/src/components/Subscriber/Subscriber.tsx @@ -53,7 +53,7 @@ const Subscriber = ({ useEffect(() => { if (subscriberWrapper && subRef.current) { const { element } = subscriberWrapper; - // eslint-disable-next-line react-hooks/immutability + element.id = subscriberWrapper.id; element.classList.add( 'video__element', diff --git a/frontend/src/components/WaitingRoom/PreviewAvatar/PreviewAvatar.tsx b/frontend/src/components/WaitingRoom/PreviewAvatar/PreviewAvatar.tsx index c99810932..4079cb032 100644 --- a/frontend/src/components/WaitingRoom/PreviewAvatar/PreviewAvatar.tsx +++ b/frontend/src/components/WaitingRoom/PreviewAvatar/PreviewAvatar.tsx @@ -50,7 +50,6 @@ const PreviewAvatar = ({ ) : ( { + it('should render without crashing', () => { + const { container } = render(); + expect(container.firstChild).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/WaitingRoom/UserNameInput/UserNameInput.skeleton.tsx b/frontend/src/components/WaitingRoom/UserNameInput/UserNameInput.skeleton.tsx new file mode 100644 index 000000000..d0633f2d0 --- /dev/null +++ b/frontend/src/components/WaitingRoom/UserNameInput/UserNameInput.skeleton.tsx @@ -0,0 +1,69 @@ +import { ReactElement } from 'react'; +import { Box } from '@mui/material'; +import classNames from 'classnames'; + +const UsernameInputSkeleton = (): ReactElement => { + return ( +
+
+ +   + + + +   + + + +   + + + + +   + + + + +   + +
+
+ ); +}; + +export default UsernameInputSkeleton; diff --git a/frontend/src/components/WaitingRoom/VideoContainer/VideoContainer.skeleton.spec.tsx b/frontend/src/components/WaitingRoom/VideoContainer/VideoContainer.skeleton.spec.tsx new file mode 100644 index 000000000..60aec190b --- /dev/null +++ b/frontend/src/components/WaitingRoom/VideoContainer/VideoContainer.skeleton.spec.tsx @@ -0,0 +1,10 @@ +import { render } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import VideoContainerSkeleton from './VideoContainer.skeleton'; + +describe('VideoContainerSkeleton', () => { + it('should render without crashing', () => { + const { container } = render(); + expect(container.firstChild).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/WaitingRoom/VideoContainer/VideoContainer.skeleton.tsx b/frontend/src/components/WaitingRoom/VideoContainer/VideoContainer.skeleton.tsx new file mode 100644 index 000000000..817b12a67 --- /dev/null +++ b/frontend/src/components/WaitingRoom/VideoContainer/VideoContainer.skeleton.tsx @@ -0,0 +1,30 @@ +import { ReactElement } from 'react'; +import classNames from 'classnames'; +import PreviewAvatar from '../PreviewAvatar'; +import VignetteEffect from '../VignetteEffect'; + +const VideoContainerSkeleton = (): ReactElement => { + return ( +
+ + + +
+ ); +}; + +export default VideoContainerSkeleton; diff --git a/frontend/src/components/WaitingRoom/VideoContainer/VideoContainer.tsx b/frontend/src/components/WaitingRoom/VideoContainer/VideoContainer.tsx index 29597ab0b..fb1f3f655 100644 --- a/frontend/src/components/WaitingRoom/VideoContainer/VideoContainer.tsx +++ b/frontend/src/components/WaitingRoom/VideoContainer/VideoContainer.tsx @@ -1,5 +1,8 @@ import { useRef, useState, useEffect, ReactElement } from 'react'; import { Stack } from '@mui/material'; +import useIsCameraControlAllowed from '@Context/AppConfig/hooks/useIsCameraControlAllowed'; +import useIsMicrophoneControlAllowed from '@Context/AppConfig/hooks/useIsMicrophoneControlAllowed'; +import useIsBackgroundEffectsAllowed from '@Context/AppConfig/hooks/useIsBackgroundEffectsAllowed'; import MicButton from '../MicButton'; import CameraButton from '../CameraButton'; import VideoLoading from '../VideoLoading'; @@ -14,9 +17,7 @@ import useIsSmallViewport from '../../../hooks/useIsSmallViewport'; import BackgroundEffectsDialog from '../BackgroundEffects/BackgroundEffectsDialog'; import BackgroundEffectsButton from '../BackgroundEffects/BackgroundEffectsButton'; -export type VideoContainerProps = { - username: string; -}; +export type VideoContainerProps = { username: string }; /** * VideoContainer Component @@ -28,6 +29,10 @@ export type VideoContainerProps = { * @returns {ReactElement} - The VideoContainer component. */ const VideoContainer = ({ username }: VideoContainerProps): ReactElement => { + const isCameraAllowed = useIsCameraControlAllowed(); + const isMicrophoneAllowed = useIsMicrophoneControlAllowed(); + const isBackgroundEffectsAllowed = useIsBackgroundEffectsAllowed(); + const containerRef = useRef(null); const [isVideoLoading, setIsVideoLoading] = useState(true); const [isBackgroundEffectsOpen, setIsBackgroundEffectsOpen] = useState(false); @@ -38,67 +43,83 @@ const VideoContainer = ({ username }: VideoContainerProps): ReactElement => { const isSmallViewport = useIsSmallViewport(); useEffect(() => { - if (publisherVideoElement && containerRef.current && isVideoEnabled) { - containerRef.current.appendChild(publisherVideoElement); - const myVideoElement = publisherVideoElement as HTMLElement; - myVideoElement.classList.add('video__element'); - // eslint-disable-next-line react-hooks/immutability - myVideoElement.title = 'publisher-preview'; - // eslint-disable-next-line react-hooks/immutability - myVideoElement.style.borderRadius = isSmallViewport ? '0px' : '12px'; - myVideoElement.style.height = isSmallViewport ? '' : '328px'; - myVideoElement.style.width = isSmallViewport ? '100dvw' : '584px'; - myVideoElement.style.marginLeft = 'auto'; - myVideoElement.style.marginRight = 'auto'; - myVideoElement.style.transform = 'scaleX(-1)'; - myVideoElement.style.objectFit = 'contain'; - myVideoElement.style.aspectRatio = '16 / 9'; - myVideoElement.style.boxShadow = - '0 1px 2px 0 rgba(60, 64, 67, .3), 0 1px 3px 1px rgba(60, 64, 67, .15)'; - - waitUntilPlaying(publisherVideoElement).then(() => { - setIsVideoLoading(false); - }); + const shouldAttachVideo = + publisherVideoElement && containerRef.current && isVideoEnabled && isCameraAllowed; + + if (!shouldAttachVideo) { + return; } - }, [isSmallViewport, publisherVideoElement, isVideoEnabled]); + + containerRef.current!.appendChild(publisherVideoElement); + const myVideoElement = publisherVideoElement as HTMLElement; + myVideoElement.classList.add('video__element'); + myVideoElement.title = 'publisher-preview'; + myVideoElement.style.borderRadius = isSmallViewport ? '0px' : '12px'; + myVideoElement.style.height = isSmallViewport ? '' : '328px'; + myVideoElement.style.width = isSmallViewport ? '100dvw' : '584px'; + myVideoElement.style.marginLeft = 'auto'; + myVideoElement.style.marginRight = 'auto'; + myVideoElement.style.transform = 'scaleX(-1)'; + myVideoElement.style.objectFit = 'contain'; + myVideoElement.style.aspectRatio = '16 / 9'; + myVideoElement.style.boxShadow = + '0 1px 2px 0 rgba(60, 64, 67, .3), 0 1px 3px 1px rgba(60, 64, 67, .15)'; + + waitUntilPlaying(publisherVideoElement).then(() => { + setIsVideoLoading(false); + }); + }, [isSmallViewport, publisherVideoElement, isVideoEnabled, isCameraAllowed]); return (