diff --git a/README.md b/README.md index 065acb07..2e49028e 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,14 @@ This application provides features for common conferencing use cases, such as: Input and output device selectors. Screenshot of audio devices selector -- Background blur and noise suppression toggles. +-
+ Noise suppression toggles in meeting room + Screenshot of noise supression toggle +
+-
+ Background effects in meeting and waiting room. You can set predefined images, custom image or slight/strong background blur. Images can be uploaded from local device or URL in these formats: JPG, PNG, GIF or BMP. + Screenshot of background effects +
-
Composed archiving capabilities to record your meetings. Screenshot of archiving dialog box diff --git a/docs/assets/BGEffects.png b/docs/assets/BGEffects.png new file mode 100644 index 00000000..07fc4436 Binary files /dev/null and b/docs/assets/BGEffects.png differ diff --git a/docs/assets/NoiseSupression.png b/docs/assets/NoiseSupression.png new file mode 100644 index 00000000..931e8ab7 Binary files /dev/null and b/docs/assets/NoiseSupression.png differ diff --git a/frontend/src/components/BackgroundEffects/AddBackgroundEffect/AddBackgroundEffect.spec.tsx b/frontend/src/components/BackgroundEffects/AddBackgroundEffect/AddBackgroundEffect.spec.tsx deleted file mode 100644 index 8dcfacac..00000000 --- a/frontend/src/components/BackgroundEffects/AddBackgroundEffect/AddBackgroundEffect.spec.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import AddBackgroundEffect from './AddBackgroundEffect'; - -describe('AddBackgroundEffect', () => { - it('renders the add photo icon', () => { - render(); - const icon = screen.getByTestId('AddPhotoAlternateIcon'); - expect(icon).toBeInTheDocument(); - }); - - it('renders the tooltip with recommended text when enabled', async () => { - render(); - const option = screen.getByTestId('background-upload'); - expect(option).toBeInTheDocument(); - }); - - it('shows disabled tooltip when isDisabled is true', () => { - render(); - const option = screen.getByTestId('background-upload'); - expect(option).toHaveAttribute('aria-disabled', 'true'); - }); - - it('shows the tooltip when hovered', async () => { - render(); - const option = screen.getByTestId('background-upload'); - await userEvent.hover(option); - - const tooltip = await screen.findByRole('tooltip'); - expect(tooltip).toBeInTheDocument(); - - expect(tooltip).toHaveTextContent(/recommended/i); - }); -}); diff --git a/frontend/src/components/BackgroundEffects/AddBackgroundEffect/AddBackgroundEffect.tsx b/frontend/src/components/BackgroundEffects/AddBackgroundEffect/AddBackgroundEffect.tsx deleted file mode 100644 index 7f33dace..00000000 --- a/frontend/src/components/BackgroundEffects/AddBackgroundEffect/AddBackgroundEffect.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { ReactElement } from 'react'; -import AddPhotoAlternateIcon from '@mui/icons-material/AddPhotoAlternate'; -import { Tooltip } from '@mui/material'; -import { useTranslation } from 'react-i18next'; -import SelectableOption from '../SelectableOption'; - -export type AddBackgroundEffectProps = { - isDisabled?: boolean; -}; - -/** - * Renders a button that allows user to upload background effects. - * - * This button is disabled if the user has reached the maximum limit of custom images. - * @param {AddBackgroundEffectProps} props - the props for the component. - * @property {boolean} isDisabled - Whether the button is disabled. - * @returns {ReactElement} A button for uploading background effects. - */ -const AddBackgroundEffect = ({ isDisabled = false }: AddBackgroundEffectProps): ReactElement => { - const { t } = useTranslation(); - return ( - - {t('backgroundEffects.recommended.specs')} -
- {t('backgroundEffects.recommended.note')} - - ) - } - arrow - > - {} - // TODO: Implement upload functionality - } - icon={} - /> -
- ); -}; - -export default AddBackgroundEffect; diff --git a/frontend/src/components/BackgroundEffects/AddBackgroundEffect/AddBackgroundEffectLayout/AddBackgroundEffectLayout.spec.tsx b/frontend/src/components/BackgroundEffects/AddBackgroundEffect/AddBackgroundEffectLayout/AddBackgroundEffectLayout.spec.tsx new file mode 100644 index 00000000..8377a411 --- /dev/null +++ b/frontend/src/components/BackgroundEffects/AddBackgroundEffect/AddBackgroundEffectLayout/AddBackgroundEffectLayout.spec.tsx @@ -0,0 +1,67 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { vi, describe, it, expect, beforeAll } from 'vitest'; +import AddBackgroundEffectLayout from './AddBackgroundEffectLayout'; + +vi.mock('../../../../utils/useImageStorage/useImageStorage', () => ({ + __esModule: true, + default: () => ({ + storageError: '', + handleImageFromFile: vi.fn(async () => ({ + dataUrl: 'data:image/png;base64,MOCKED', + })), + handleImageFromLink: vi.fn(async () => ({ + dataUrl: 'data:image/png;base64,MOCKED_LINK', + })), + }), +})); + +describe('AddBackgroundEffectLayout', () => { + const cb = vi.fn(); + + beforeAll(() => { + vi.clearAllMocks(); + }); + + it('should render', () => { + render(); + expect(screen.getByText(/Drag and drop, or click here to upload image/i)).toBeInTheDocument(); + expect(screen.getByPlaceholderText(/Link from the web/i)).toBeInTheDocument(); + expect(screen.getByTestId('background-effect-link-submit-button')).toBeInTheDocument(); + }); + + it('shows error for invalid file type', async () => { + render(); + const input = screen.getByLabelText(/upload/i); + const file = new File(['dummy'], 'test.txt', { type: 'text/plain' }); + fireEvent.change(input, { target: { files: [file] } }); + expect( + await screen.findByText(/Only JPG, PNG, GIF, or BMP images are allowed/i) + ).toBeInTheDocument(); + }); + + it('shows error for file size too large', async () => { + render(); + const input = screen.getByLabelText(/upload/i); + const file = new File(['x'.repeat(3 * 1024 * 1024)], 'big.png', { type: 'image/png' }); + Object.defineProperty(file, 'size', { value: 3 * 1024 * 1024 }); + fireEvent.change(input, { target: { files: [file] } }); + expect(await screen.findByText(/Image must be less than 2MB/i)).toBeInTheDocument(); + }); + + it('handles valid image file upload', async () => { + render(); + const input = screen.getByLabelText(/upload/i); + const file = new File(['dummy'], 'test.png', { type: 'image/png' }); + fireEvent.change(input, { target: { files: [file] } }); + await waitFor(() => expect(cb).toHaveBeenCalledWith('data:image/png;base64,MOCKED')); + }); + + it('handles valid link submit', async () => { + render(); + const input = screen.getByPlaceholderText(/Link from the web/i); + fireEvent.change(input, { target: { value: 'https://example.com/image.png' } }); + const button = screen.getByTestId('background-effect-link-submit-button'); + fireEvent.click(button); + await waitFor(() => expect(cb).toHaveBeenCalledWith('data:image/png;base64,MOCKED_LINK')); + }); +}); diff --git a/frontend/src/components/BackgroundEffects/AddBackgroundEffect/AddBackgroundEffectLayout/AddBackgroundEffectLayout.tsx b/frontend/src/components/BackgroundEffects/AddBackgroundEffect/AddBackgroundEffectLayout/AddBackgroundEffectLayout.tsx new file mode 100644 index 00000000..1e1d108f --- /dev/null +++ b/frontend/src/components/BackgroundEffects/AddBackgroundEffect/AddBackgroundEffectLayout/AddBackgroundEffectLayout.tsx @@ -0,0 +1,138 @@ +import { + Box, + Button, + CircularProgress, + InputAdornment, + TextField, + Typography, +} from '@mui/material'; +import { ChangeEvent, ReactElement, useState } from 'react'; +import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; +import LinkIcon from '@mui/icons-material/Link'; +import FileUploader from '../../FileUploader/FileUploader'; +import { ALLOWED_TYPES, MAX_SIZE_MB } from '../../../../utils/constants'; +import useImageStorage from '../../../../utils/useImageStorage/useImageStorage'; + +export type AddBackgroundEffectLayoutProps = { + customBackgroundImageChange: (dataUrl: string) => void; +}; + +/** + * AddBackgroundEffectLayout Component + * + * This component manages the UI for adding background effects. + * @param {AddBackgroundEffectLayoutProps} props - The props for the component. + * @property {Function} customBackgroundImageChange - Callback function to handle background image change. + * @returns {ReactElement} The add background effect layout component. + */ +const AddBackgroundEffectLayout = ({ + customBackgroundImageChange, +}: AddBackgroundEffectLayoutProps): ReactElement => { + const [fileError, setFileError] = useState(''); + const [imageLink, setImageLink] = useState(''); + const [linkLoading, setLinkLoading] = useState(false); + const { storageError, handleImageFromFile, handleImageFromLink } = useImageStorage(); + + type HandleFileChangeType = ChangeEvent | { target: { files: FileList } }; + + const handleFileChange = async (e: HandleFileChangeType) => { + const { files } = e.target; + if (!files || files.length === 0) { + return; + } + + const file = files[0]; + if (!file) { + return; + } + + if (!ALLOWED_TYPES.includes(file.type)) { + setFileError('Only JPG, PNG, GIF, or BMP images are allowed.'); + return; + } + + if (file.size > MAX_SIZE_MB * 1024 * 1024) { + setFileError(`Image must be less than ${MAX_SIZE_MB}MB.`); + return; + } + + try { + const newImage = await handleImageFromFile(file); + if (newImage) { + setFileError(''); + customBackgroundImageChange(newImage.dataUrl); + } + } catch { + setFileError('Failed to process uploaded image.'); + } + }; + + const handleLinkSubmit = async () => { + setFileError(''); + setLinkLoading(true); + try { + const newImage = await handleImageFromLink(imageLink); + if (newImage) { + setFileError(''); + customBackgroundImageChange(newImage.dataUrl); + } else { + setFileError('Failed to store image.'); + } + } catch { + // error handled in hook + } finally { + setLinkLoading(false); + } + }; + + return ( + + + + {(fileError || storageError) && ( + + {fileError || storageError} + + )} + + + setImageLink(e.target.value)} + InputProps={{ + startAdornment: ( + + {linkLoading ? : } + + ), + }} + /> + + + + + ); +}; + +export default AddBackgroundEffectLayout; diff --git a/frontend/src/components/BackgroundEffects/AddBackgroundEffect/AddBackgroundEffectLayout/Index.tsx b/frontend/src/components/BackgroundEffects/AddBackgroundEffect/AddBackgroundEffectLayout/Index.tsx new file mode 100644 index 00000000..ae8ccca1 --- /dev/null +++ b/frontend/src/components/BackgroundEffects/AddBackgroundEffect/AddBackgroundEffectLayout/Index.tsx @@ -0,0 +1,3 @@ +import AddBackgroundEffectLayout from './AddBackgroundEffectLayout'; + +export default AddBackgroundEffectLayout; diff --git a/frontend/src/components/BackgroundEffects/AddBackgroundEffect/Index.tsx b/frontend/src/components/BackgroundEffects/AddBackgroundEffect/Index.tsx deleted file mode 100644 index ba0fb702..00000000 --- a/frontend/src/components/BackgroundEffects/AddBackgroundEffect/Index.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import AddBackgroundEffect from './AddBackgroundEffect'; - -export default AddBackgroundEffect; diff --git a/frontend/src/components/BackgroundEffects/BackgroundEffectTabs/BackgroundEffectTabs.spec.tsx b/frontend/src/components/BackgroundEffects/BackgroundEffectTabs/BackgroundEffectTabs.spec.tsx new file mode 100644 index 00000000..8bd2d32d --- /dev/null +++ b/frontend/src/components/BackgroundEffects/BackgroundEffectTabs/BackgroundEffectTabs.spec.tsx @@ -0,0 +1,63 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { vi, describe, it, expect } from 'vitest'; +import BackgroundEffectTabs from './BackgroundEffectTabs'; + +describe('BackgroundEffectTabs', () => { + const setTabSelected = vi.fn(); + const setBackgroundSelected = vi.fn(); + const clearBgWhenSelectedDeleted = vi.fn(); + const customBackgroundImageChange = vi.fn(); + + it('renders tabs and defaults to Backgrounds tab', () => { + render( + + ); + expect(screen.getByRole('tab', { name: /Backgrounds/i })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: /Add Background/i })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: /Backgrounds/i })).toHaveAttribute( + 'aria-selected', + 'true' + ); + }); + + it('switches to Add Background tab when clicked', async () => { + render( + + ); + const addTab = screen.getByRole('tab', { name: /Add Background/i }); + await userEvent.click(addTab); + expect(setTabSelected).toHaveBeenCalledWith(1); + }); + + it('renders AddBackgroundEffectLayout when Add Background tab is selected', () => { + render( + + ); + expect(screen.getByText(/upload/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/BackgroundEffects/BackgroundEffectTabs/BackgroundEffectTabs.tsx b/frontend/src/components/BackgroundEffects/BackgroundEffectTabs/BackgroundEffectTabs.tsx new file mode 100644 index 00000000..6a1541e8 --- /dev/null +++ b/frontend/src/components/BackgroundEffects/BackgroundEffectTabs/BackgroundEffectTabs.tsx @@ -0,0 +1,121 @@ +import { Box, Tabs, Tab } from '@mui/material'; +import { Publisher } from '@vonage/client-sdk-video'; +import { ReactElement } from 'react'; +import EffectOptionButtons from '../EffectOptionButtons/EffectOptionButtons'; +import BackgroundGallery from '../BackgroundGallery/BackgroundGallery'; +import AddBackgroundEffectLayout from '../AddBackgroundEffect/AddBackgroundEffectLayout/AddBackgroundEffectLayout'; +import { DEFAULT_SELECTABLE_OPTION_WIDTH } from '../../../utils/constants'; +import getInitialBackgroundFilter from '../../../utils/backgroundFilter/getInitialBackgroundFilter/getInitialBackgroundFilter'; +import useIsTabletViewport from '../../../hooks/useIsTabletViewport'; + +type BackgroundEffectTabsProps = { + tabSelected: number; + setTabSelected: (value: number) => void; + mode: 'meeting' | 'waiting'; + backgroundSelected: string; + setBackgroundSelected: (value: string) => void; + cleanupSelectedBackgroundReplacement: (dataUrl: string) => void; + customBackgroundImageChange: (dataUrl: string) => void; +}; + +export const clearBgWhenSelectedDeleted = ( + publisher: Publisher | null | undefined, + changeBackground: (bg: string) => void, + backgroundSelected: string, + dataUrl: string +) => { + const selectedBackgroundOption = getInitialBackgroundFilter(publisher); + if (dataUrl === selectedBackgroundOption) { + changeBackground(backgroundSelected); + } +}; + +/** + * BackgroundEffectTabs Component + * + * This component manages the tabs for background effects, including selecting existing backgrounds + * and adding new ones. + * @param {BackgroundEffectTabsProps} props - The props for the component. + * @property {number} tabSelected - The currently selected tab index. + * @property {Function} setTabSelected - Function to set the selected tab index. + * @property {string} mode - The mode of the background effect ('meeting' or 'waiting'). + * @property {string} backgroundSelected - The currently selected background option. + * @property {Function} setBackgroundSelected - Function to set the selected background option. + * @property {Function} cleanupSelectedBackgroundReplacement - Function to clean up background replacement if deleted. + * @property {Function} customBackgroundImageChange - Callback function to handle background image change. + * @returns {ReactElement} The background effect tabs component. + */ +const BackgroundEffectTabs = ({ + tabSelected, + setTabSelected, + mode, + backgroundSelected, + setBackgroundSelected, + cleanupSelectedBackgroundReplacement, + customBackgroundImageChange, +}: BackgroundEffectTabsProps): ReactElement => { + const handleBackgroundSelect = (value: string) => { + setBackgroundSelected(value); + }; + const isTabletViewport = useIsTabletViewport(); + + return ( + + setTabSelected(newValue)} + aria-label="backgrounds tabs" + > + + + + + + {tabSelected === 0 && ( + + + + + )} + {tabSelected === 1 && ( + + )} + + + ); +}; + +export default BackgroundEffectTabs; diff --git a/frontend/src/components/BackgroundEffects/BackgroundEffectTabs/Index.tsx b/frontend/src/components/BackgroundEffects/BackgroundEffectTabs/Index.tsx new file mode 100644 index 00000000..2bad5f92 --- /dev/null +++ b/frontend/src/components/BackgroundEffects/BackgroundEffectTabs/Index.tsx @@ -0,0 +1,3 @@ +import BackgroundEffectTabs from './BackgroundEffectTabs'; + +export default BackgroundEffectTabs; diff --git a/frontend/src/components/MeetingRoom/BackgroundEffectsLayout/BackgroundEffectsLayout.spec.tsx b/frontend/src/components/BackgroundEffects/BackgroundEffectsLayout/BackgroundEffectsLayout.spec.tsx similarity index 51% rename from frontend/src/components/MeetingRoom/BackgroundEffectsLayout/BackgroundEffectsLayout.spec.tsx rename to frontend/src/components/BackgroundEffects/BackgroundEffectsLayout/BackgroundEffectsLayout.spec.tsx index 641be457..dc25fb33 100644 --- a/frontend/src/components/MeetingRoom/BackgroundEffectsLayout/BackgroundEffectsLayout.spec.tsx +++ b/frontend/src/components/BackgroundEffects/BackgroundEffectsLayout/BackgroundEffectsLayout.spec.tsx @@ -6,6 +6,20 @@ import enTranslations from '../../../locales/en.json'; const mockChangeBackground = vi.fn(); +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + 'backgroundEffects.title': enTranslations['backgroundEffects.title'], + 'backgroundEffects.choice': enTranslations['backgroundEffects.choice'], + 'button.cancel': enTranslations['button.cancel'], + 'button.apply': enTranslations['button.apply'], + }; + return translations[key] || key; + }, + }), +})); + vi.mock('../../../hooks/usePublisherContext', () => ({ __esModule: true, default: () => ({ @@ -16,7 +30,16 @@ vi.mock('../../../hooks/usePublisherContext', () => ({ isVideoEnabled: true, }), })); - +vi.mock('../../../hooks/usePreviewPublisherContext', () => ({ + __esModule: true, + default: () => ({ + publisher: { + getVideoFilter: vi.fn(() => undefined), + }, + changeBackground: mockChangeBackground, + isVideoEnabled: true, + }), +})); vi.mock('../../../hooks/useBackgroundPublisherContext', () => ({ __esModule: true, default: () => ({ @@ -25,24 +48,10 @@ vi.mock('../../../hooks/useBackgroundPublisherContext', () => ({ }), })); -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => { - const translations: Record = { - 'backgroundEffects.title': enTranslations['backgroundEffects.title'], - 'backgroundEffects.choice': enTranslations['backgroundEffects.choice'], - 'button.cancel': enTranslations['button.cancel'], - 'button.apply': enTranslations['button.apply'], - }; - return translations[key] || key; - }, - }), -})); - -describe('BackgroundEffectsLayout', () => { +describe('BackgroundEffectsLayout (Meeting room)', () => { const handleClose = vi.fn(); const renderLayout = (isOpen = true) => - render(); + render(); beforeEach(() => { vi.clearAllMocks(); @@ -53,11 +62,71 @@ describe('BackgroundEffectsLayout', () => { expect(screen.getByTestId('right-panel-title')).toHaveTextContent('Background Effects'); expect(screen.getByTestId('background-video-container')).toBeInTheDocument(); expect(screen.getByTestId('background-none')).toBeInTheDocument(); - expect(screen.getByTestId('background-upload')).toBeInTheDocument(); expect(screen.getByTestId('background-bg1')).toBeInTheDocument(); - expect(screen.getAllByText(/Choose Background Effect/i)[0]).toBeInTheDocument(); expect(screen.getByTestId('background-effect-cancel-button')).toBeInTheDocument(); expect(screen.getByTestId('background-effect-apply-button')).toBeInTheDocument(); + + expect(screen.getAllByText(/Backgrounds/i)[0]).toBeInTheDocument(); + expect(screen.getAllByText(/Add background/i)[0]).toBeInTheDocument(); + }); + + it('does not render when closed', () => { + const { container } = renderLayout(false); + expect(container).toBeEmptyDOMElement(); + }); + + it('calls handleClose when Cancel is clicked', async () => { + renderLayout(); + await userEvent.click(screen.getByTestId('background-effect-cancel-button')); + expect(handleClose).toHaveBeenCalled(); + }); + + it('calls handleClose and changeBackground when Apply is clicked', async () => { + renderLayout(); + await userEvent.click(screen.getByTestId('background-effect-apply-button')); + expect(mockChangeBackground).toHaveBeenCalled(); + expect(handleClose).toHaveBeenCalled(); + }); + + it('calls setBackgroundSelected when effect option none is clicked', async () => { + renderLayout(); + await userEvent.click(screen.getByTestId('background-none')); + }); + + it('calls setBackgroundSelected when a background gallery option is clicked', async () => { + renderLayout(); + await userEvent.click(screen.getByTestId('background-bg8')); + }); + + it('displays correct English title, subtitle, cancel, and apply actions', () => { + renderLayout(); + expect(screen.getByText('Backgrounds')).toBeInTheDocument(); + expect(screen.getByText('Add Background')).toBeInTheDocument(); + expect(screen.getByText('Cancel')).toBeInTheDocument(); + expect(screen.getByText('Apply')).toBeInTheDocument(); + }); +}); + +describe('BackgroundEffects (Waiting Room)', () => { + const handleClose = vi.fn(); + const renderLayout = (isOpen = true) => + render(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders when open', () => { + renderLayout(); + expect(screen.getByTestId('background-video-container')).toBeInTheDocument(); + expect(screen.getByTestId('background-none')).toBeInTheDocument(); + expect(screen.getByTestId('background-bg1')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Cancel/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Apply/i })).toBeInTheDocument(); + + // Checking that BackgroundEffectTabs (Backgrounds and Add Background tabs) are rendered + expect(screen.getAllByText(/Backgrounds/i)[0]).toBeInTheDocument(); + expect(screen.getAllByText(/Add background/i)[0]).toBeInTheDocument(); }); it('does not render when closed', () => { @@ -90,8 +159,8 @@ describe('BackgroundEffectsLayout', () => { it('displays correct English title, subtitle, cancel, and apply actions', () => { renderLayout(); - expect(screen.getByText('Background Effects')).toBeInTheDocument(); - expect(screen.getByText('Choose Background Effect')).toBeInTheDocument(); + expect(screen.getByText('Backgrounds')).toBeInTheDocument(); + expect(screen.getByText('Add Background')).toBeInTheDocument(); expect(screen.getByText('Cancel')).toBeInTheDocument(); expect(screen.getByText('Apply')).toBeInTheDocument(); }); diff --git a/frontend/src/components/BackgroundEffects/BackgroundEffectsLayout/BackgroundEffectsLayout.tsx b/frontend/src/components/BackgroundEffects/BackgroundEffectsLayout/BackgroundEffectsLayout.tsx new file mode 100644 index 00000000..89535035 --- /dev/null +++ b/frontend/src/components/BackgroundEffects/BackgroundEffectsLayout/BackgroundEffectsLayout.tsx @@ -0,0 +1,193 @@ +import { ReactElement, useCallback, useEffect, useState } from 'react'; +import { Box, Button, useMediaQuery } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import usePublisherContext from '../../../hooks/usePublisherContext'; +import BackgroundVideoContainer from '../BackgroundVideoContainer'; +import BackgroundEffectTabs, { + clearBgWhenSelectedDeleted, +} from '../BackgroundEffectTabs/BackgroundEffectTabs'; +import getInitialBackgroundFilter from '../../../utils/backgroundFilter/getInitialBackgroundFilter/getInitialBackgroundFilter'; +import useBackgroundPublisherContext from '../../../hooks/useBackgroundPublisherContext'; +import RightPanelTitle from '../../MeetingRoom/RightPanel/RightPanelTitle'; +import usePreviewPublisherContext from '../../../hooks/usePreviewPublisherContext'; +import useIsTabletViewport from '../../../hooks/useIsTabletViewport'; + +export type BackgroundEffectsLayoutProps = { + isOpen: boolean; + handleClose: () => void; + mode: 'meeting' | 'waiting'; +}; + +/** + * BackgroundEffectsLayout Component + * + * This component provides a layout for managing background effects, including video preview, + * effect selection tabs, and action buttons. + * @param {BackgroundEffectsLayoutProps} props - The properties for the component. + * @property {boolean} isOpen - Whether the background effects layout is open. + * @property {Function} handleClose - Function to close the background effects layout. + * @property {string} mode - The mode of the background effects ('meeting' or 'waiting'). + * @returns {ReactElement | false} The background effects layout component or false if not open. + */ +const BackgroundEffectsLayout = ({ + isOpen, + handleClose, + mode, +}: BackgroundEffectsLayoutProps): ReactElement | false => { + const [tabSelected, setTabSelected] = useState(0); + const [backgroundSelected, setBackgroundSelected] = useState('none'); + const { t } = useTranslation(); + + const isShortScreen = useMediaQuery('(max-height:825px)'); + const isTabletViewport = useIsTabletViewport(); + + const publisherContext = usePublisherContext(); + const previewPublisherContext = usePreviewPublisherContext(); + + const { publisher, changeBackground, isVideoEnabled } = + mode === 'meeting' ? publisherContext : previewPublisherContext; + + const { publisherVideoElement, changeBackground: changeBackgroundPreview } = + useBackgroundPublisherContext(); + + const handleBackgroundSelect = (selectedBackgroundOption: string) => { + setBackgroundSelected(selectedBackgroundOption); + changeBackgroundPreview(selectedBackgroundOption); + }; + + const handleApplyBackgroundSelect = () => { + changeBackground(backgroundSelected); + handleClose(); + }; + + const customBackgroundImageChange = (dataUrl: string) => { + setTabSelected(0); + handleBackgroundSelect(dataUrl); + }; + + const setInitialBackgroundReplacement = useCallback(() => { + const selectedBackgroundOption = getInitialBackgroundFilter(publisher); + setBackgroundSelected(selectedBackgroundOption); + return selectedBackgroundOption; + }, [publisher]); + + useEffect(() => { + if (isOpen) { + const currentOption = setInitialBackgroundReplacement(); + changeBackgroundPreview(currentOption); + } + }, [isOpen, publisher, changeBackgroundPreview, setInitialBackgroundReplacement]); + + const buttonGroup = ( + + + + + ); + + if (!isOpen) { + return false; + } + + // MeetingRoom layout + if (mode === 'meeting') { + return ( + + + + + + + + + clearBgWhenSelectedDeleted(publisher, changeBackground, backgroundSelected, dataUrl) + } + customBackgroundImageChange={customBackgroundImageChange} + /> + + {buttonGroup} + + ); + } + + // WaitingRoom layout + return ( + + + + + + {!isTabletViewport && buttonGroup} + + + + clearBgWhenSelectedDeleted(publisher, changeBackground, backgroundSelected, dataUrl) + } + customBackgroundImageChange={customBackgroundImageChange} + /> + + {isTabletViewport && buttonGroup} + + ); +}; + +export default BackgroundEffectsLayout; diff --git a/frontend/src/components/MeetingRoom/BackgroundEffectsLayout/Index.tsx b/frontend/src/components/BackgroundEffects/BackgroundEffectsLayout/index.tsx similarity index 100% rename from frontend/src/components/MeetingRoom/BackgroundEffectsLayout/Index.tsx rename to frontend/src/components/BackgroundEffects/BackgroundEffectsLayout/index.tsx diff --git a/frontend/src/components/BackgroundEffects/BackgroundGallery/BackgroundGallery.spec.tsx b/frontend/src/components/BackgroundEffects/BackgroundGallery/BackgroundGallery.spec.tsx index 799f10db..43baa6a4 100644 --- a/frontend/src/components/BackgroundEffects/BackgroundGallery/BackgroundGallery.spec.tsx +++ b/frontend/src/components/BackgroundEffects/BackgroundGallery/BackgroundGallery.spec.tsx @@ -1,34 +1,132 @@ -import { describe, expect, it, vi } from 'vitest'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import BackgroundGallery, { backgrounds } from './BackgroundGallery'; +const customImages = [ + { id: 'custom1', dataUrl: 'data:image/png;base64,custom1' }, + { id: 'custom2', dataUrl: 'data:image/png;base64,custom2' }, +]; + +const mockDeleteImageFromStorage = vi.fn(); +const mockGetImagesFromStorage = vi.fn(() => customImages); + +vi.mock('../../../utils/useImageStorage/useImageStorage', () => ({ + __esModule: true, + default: () => ({ + getImagesFromStorage: mockGetImagesFromStorage, + deleteImageFromStorage: mockDeleteImageFromStorage, + }), +})); + describe('BackgroundGallery', () => { - const backgroundsFiles = backgrounds.map((bg) => bg.file); + beforeEach(() => { + vi.clearAllMocks(); + }); - it('renders all background images as selectable options', () => { - render( {}} />); - const imgs = screen.getAllByRole('img'); - backgroundsFiles.forEach((file) => { - expect(imgs.some((img) => (img as HTMLImageElement).src.includes(file))).toBe(true); + it('renders all built-in backgrounds as selectable options', () => { + render( + {}} + clearPublisherBgIfSelectedDeleted={() => {}} + /> + ); + backgrounds.forEach((bg) => { + expect(screen.getByTestId(`background-${bg.id}`)).toBeInTheDocument(); }); - const options = backgrounds.map((bg) => screen.getByTestId(`background-${bg.id}`)); - expect(options).toHaveLength(backgroundsFiles.length); }); - it('sets the selected background', async () => { + it('renders custom images as selectable options', () => { + render( + {}} + clearPublisherBgIfSelectedDeleted={() => {}} + /> + ); + customImages.forEach((img) => { + expect(screen.getByTestId(`background-${img.id}`)).toBeInTheDocument(); + }); + }); + + it('sets the selected built-in background', async () => { const setBackgroundSelected = vi.fn(); render( - + {}} + /> ); const duneViewOption = screen.getByTestId('background-bg3'); await userEvent.click(duneViewOption); expect(setBackgroundSelected).toHaveBeenCalledWith('dune-view.jpg'); }); - it('marks the background as selected', () => { - render( {}} />); + it('sets the selected custom image', async () => { + const setBackgroundSelected = vi.fn(); + render( + {}} + /> + ); + const customOption = screen.getByTestId('background-custom1'); + await userEvent.click(customOption); + expect(setBackgroundSelected).toHaveBeenCalledWith('data:image/png;base64,custom1'); + }); + + it('marks the built-in background as selected', () => { + render( + {}} + clearPublisherBgIfSelectedDeleted={() => {}} + /> + ); const planeOption = screen.getByTestId('background-bg7'); - expect(planeOption?.getAttribute('aria-pressed')).toBe('true'); + expect(planeOption.getAttribute('aria-pressed')).toBe('true'); + }); + + it('marks the custom image as selected', () => { + render( + {}} + clearPublisherBgIfSelectedDeleted={() => {}} + /> + ); + const customOption = screen.getByTestId('background-custom2'); + expect(customOption.getAttribute('aria-pressed')).toBe('true'); + }); + + it('calls deleteImageFromStorage and cleans publisher when deleting a custom image', async () => { + const cleanPublisher = vi.fn(); + render( + {}} + clearPublisherBgIfSelectedDeleted={cleanPublisher} + /> + ); + const deleteButtons = screen.getAllByLabelText('Delete custom background'); + await userEvent.click(deleteButtons[0]); + expect(mockDeleteImageFromStorage).toHaveBeenCalledWith('custom1'); + expect(cleanPublisher).toHaveBeenCalledWith('data:image/png;base64,custom1'); + }); + + it("doesn't delete custom image if it's selected", async () => { + render( + {}} + clearPublisherBgIfSelectedDeleted={() => {}} + /> + ); + const deleteButton = screen.getByTestId('background-delete-custom1'); + await userEvent.click(deleteButton); + expect(mockDeleteImageFromStorage).not.toHaveBeenCalled(); }); }); diff --git a/frontend/src/components/BackgroundEffects/BackgroundGallery/BackgroundGallery.tsx b/frontend/src/components/BackgroundEffects/BackgroundGallery/BackgroundGallery.tsx index 50b6b17d..bb5d4a84 100644 --- a/frontend/src/components/BackgroundEffects/BackgroundGallery/BackgroundGallery.tsx +++ b/frontend/src/components/BackgroundEffects/BackgroundGallery/BackgroundGallery.tsx @@ -1,22 +1,25 @@ -import { ReactElement } from 'react'; -import { Box } from '@mui/material'; +import { ReactElement, useEffect, useState } from 'react'; +import { Box, IconButton, Tooltip } from '@mui/material'; +import DeleteIcon from '@mui/icons-material/Delete'; import { BACKGROUNDS_PATH } from '../../../utils/constants'; import SelectableOption from '../SelectableOption'; +import useImageStorage, { StoredImage } from '../../../utils/useImageStorage/useImageStorage'; export const backgrounds = [ - { id: 'bg1', file: 'bookshelf-room.jpg' }, - { id: 'bg2', file: 'busy-room.jpg' }, - { id: 'bg3', file: 'dune-view.jpg' }, - { id: 'bg4', file: 'hogwarts.jpg' }, - { id: 'bg5', file: 'library.jpg' }, - { id: 'bg6', file: 'new-york.jpg' }, - { id: 'bg7', file: 'plane.jpg' }, - { id: 'bg8', file: 'white-room.jpg' }, + { id: 'bg1', file: 'bookshelf-room.jpg', name: 'Bookshelf Room' }, + { id: 'bg2', file: 'busy-room.jpg', name: 'Busy Room' }, + { id: 'bg3', file: 'dune-view.jpg', name: 'Dune View' }, + { id: 'bg4', file: 'hogwarts.jpg', name: 'Hogwarts' }, + { id: 'bg5', file: 'library.jpg', name: 'Library' }, + { id: 'bg6', file: 'new-york.jpg', name: 'New York' }, + { id: 'bg7', file: 'plane.jpg', name: 'Plane' }, + { id: 'bg8', file: 'white-room.jpg', name: 'White Room' }, ]; export type BackgroundGalleryProps = { backgroundSelected: string; - setBackgroundSelected: (key: string) => void; + setBackgroundSelected: (dataUrl: string) => void; + clearPublisherBgIfSelectedDeleted: (dataUrl: string) => void; }; /** @@ -26,33 +29,101 @@ export type BackgroundGalleryProps = { * @param {BackgroundGalleryProps} props - The props for the component. * @property {string} backgroundSelected - The currently selected background image key. * @property {Function} setBackgroundSelected - Callback to update the selected background image key. + * @property {Function} clearPublisherBgIfSelectedDeleted - Callback to clean up background replacement if the selected background is deleted. * @returns {ReactElement} A horizontal stack of selectable option buttons. */ const BackgroundGallery = ({ backgroundSelected, setBackgroundSelected, + clearPublisherBgIfSelectedDeleted, }: BackgroundGalleryProps): ReactElement => { + const { getImagesFromStorage, deleteImageFromStorage } = useImageStorage(); + const [customImages, setCustomImages] = useState([]); + + useEffect(() => { + setCustomImages(getImagesFromStorage()); + }, [getImagesFromStorage]); + + const handleDelete = (id: string, dataUrl: string) => { + if (backgroundSelected === dataUrl) { + return; + } + deleteImageFromStorage(id); + setCustomImages((imgs) => imgs.filter((img) => img.id !== id)); + clearPublisherBgIfSelectedDeleted(dataUrl); + }; + return ( <> - {backgrounds.map((bg) => { - const path = `${BACKGROUNDS_PATH}/${bg.file}`; + {customImages.map(({ id, dataUrl }) => { + const isSelected = backgroundSelected === dataUrl; return ( setBackgroundSelected(bg.file)} - image={path} - /> + id={id} + title="Your Background" + isSelected={isSelected} + onClick={() => setBackgroundSelected(dataUrl)} + image={dataUrl} + > + + { + e.stopPropagation(); + if (!isSelected) { + handleDelete(id, dataUrl); + } + }} + size="small" + sx={{ + color: 'white', + position: 'absolute', + top: -8, + right: -8, + zIndex: 10, + cursor: isSelected ? 'default' : 'pointer', + backgroundColor: isSelected ? 'rgba(0,0,0,0.4)' : 'rgba(0,0,0,0.8)', + '&:hover': { + backgroundColor: isSelected ? 'rgba(0,0,0,0.4)' : 'rgba(0,0,0,0.8)', + }, + }} + > + + + + ); })} + + {backgrounds.map((bg) => { + const path = `${BACKGROUNDS_PATH}/${bg.file}`; + return ( + setBackgroundSelected(bg.file)} + image={path} + /> + ); + })} ); }; diff --git a/frontend/src/components/BackgroundEffects/BackgroundVideoContainer/BackgroundVideoContainer.tsx b/frontend/src/components/BackgroundEffects/BackgroundVideoContainer/BackgroundVideoContainer.tsx index 68e78d89..1fef99f6 100644 --- a/frontend/src/components/BackgroundEffects/BackgroundVideoContainer/BackgroundVideoContainer.tsx +++ b/frontend/src/components/BackgroundEffects/BackgroundVideoContainer/BackgroundVideoContainer.tsx @@ -2,6 +2,7 @@ import { useRef, useState, useEffect, ReactElement } from 'react'; import { CircularProgress, useMediaQuery } from '@mui/material'; import { useTranslation } from 'react-i18next'; import waitUntilPlaying from '../../../utils/waitUntilPlaying'; +import useIsTabletViewport from '../../../hooks/useIsTabletViewport'; export type BackgroundVideoContainerProps = { isFixedWidth?: boolean; @@ -27,7 +28,8 @@ const BackgroundVideoContainer = ({ const [isVideoLoading, setIsVideoLoading] = useState(true); const isSMViewport = useMediaQuery(`(max-width:500px)`); const isMDViewport = useMediaQuery(`(max-width:768px)`); - const isTabletViewport = useMediaQuery(`(max-width:899px)`); + const isTabletViewport = useIsTabletViewport(); + const isLGViewport = useMediaQuery(`(max-width:1199px)`); useEffect(() => { if (publisherVideoElement && containerRef.current) { @@ -39,8 +41,12 @@ const BackgroundVideoContainer = ({ myVideoElement.style.maxHeight = isTabletViewport ? '80%' : '450px'; let width = '100%'; - if ((isFixedWidth && isTabletViewport) || (!isFixedWidth && isMDViewport)) { - width = '80%'; + if ( + (isFixedWidth && isTabletViewport) || + (!isFixedWidth && isMDViewport) || + (isLGViewport && isFixedWidth) + ) { + width = '90%'; } myVideoElement.style.width = width; @@ -64,6 +70,7 @@ const BackgroundVideoContainer = ({ publisherVideoElement, isFixedWidth, isParentVideoEnabled, + isLGViewport, ]); let containerWidth = '100%'; @@ -74,7 +81,7 @@ const BackgroundVideoContainer = ({ } return ( -
+
{!isParentVideoEnabled && (
{t('backgroundEffects.video.disabled')} diff --git a/frontend/src/components/BackgroundEffects/EffectOptionButtons/EffectOptionButtons.tsx b/frontend/src/components/BackgroundEffects/EffectOptionButtons/EffectOptionButtons.tsx index 7b747898..55d0dea2 100644 --- a/frontend/src/components/BackgroundEffects/EffectOptionButtons/EffectOptionButtons.tsx +++ b/frontend/src/components/BackgroundEffects/EffectOptionButtons/EffectOptionButtons.tsx @@ -4,9 +4,13 @@ import BlurOnIcon from '@mui/icons-material/BlurOn'; import SelectableOption from '../SelectableOption'; const options = [ - { key: 'none', icon: }, - { key: 'low-blur', icon: }, - { key: 'high-blur', icon: }, + { key: 'none', icon: , name: 'Remove background' }, + { key: 'low-blur', icon: , name: 'Slight background blur' }, + { + key: 'high-blur', + icon: , + name: 'Strong background blur', + }, ]; export type EffectOptionButtonsProps = { @@ -29,10 +33,11 @@ const EffectOptionButtons = ({ }: EffectOptionButtonsProps): ReactElement => { return ( <> - {options.map(({ key, icon }) => ( + {options.map(({ key, icon, name }) => ( setBackgroundSelected(key)} icon={icon} diff --git a/frontend/src/components/BackgroundEffects/FileUploader/FileUploader.spec.tsx b/frontend/src/components/BackgroundEffects/FileUploader/FileUploader.spec.tsx new file mode 100644 index 00000000..364d229b --- /dev/null +++ b/frontend/src/components/BackgroundEffects/FileUploader/FileUploader.spec.tsx @@ -0,0 +1,47 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { it, vi, describe, expect, beforeAll } from 'vitest'; +import FileUploader from './FileUploader'; + +describe('FileUploader', () => { + const handleFileChange = vi.fn(); + + beforeAll(() => { + vi.clearAllMocks(); + }); + + it('renders upload UI', () => { + render(); + expect(screen.getByText(/Drag and drop, or click here to upload image/i)).toBeInTheDocument(); + expect(screen.getByTestId('file-upload-input')).toBeInTheDocument(); + expect(screen.getByTestId('file-upload-drop-area')).toBeInTheDocument(); + }); + + it('handles file input change', () => { + render(); + const input = screen.getByTestId('file-upload-input'); + const file = new File(['dummy'], 'test.png', { type: 'image/png' }); + fireEvent.change(input, { target: { files: [file] } }); + expect(handleFileChange).toHaveBeenCalled(); + }); + + it('handles file drop event', () => { + render(); + const box = screen.getByTestId('file-upload-drop-area'); + const file = new File(['dummy'], 'test.jpg', { type: 'image/jpeg' }); + const dataTransfer = { + files: [file], + clearData: vi.fn(), + }; + fireEvent.drop(box, { dataTransfer }); + expect(handleFileChange).toHaveBeenCalledWith({ target: { files: [file] } }); + }); + + it('shows drag over style when dragging', () => { + render(); + const box = screen.getByTestId('file-upload-drop-area'); + fireEvent.dragOver(box); + expect(box).toHaveStyle('border: 2px dashed #1976d2'); + fireEvent.dragLeave(box); + expect(box).toHaveStyle('border: 1px dashed #C1C1C1'); + }); +}); diff --git a/frontend/src/components/BackgroundEffects/FileUploader/FileUploader.tsx b/frontend/src/components/BackgroundEffects/FileUploader/FileUploader.tsx new file mode 100644 index 00000000..188b1fe4 --- /dev/null +++ b/frontend/src/components/BackgroundEffects/FileUploader/FileUploader.tsx @@ -0,0 +1,82 @@ +import { ChangeEvent, useState, DragEvent, ReactElement } from 'react'; +import { Box, Typography } from '@mui/material'; +import CloudUploadIcon from '@mui/icons-material/CloudUpload'; +import { MAX_SIZE_MB } from '../../../utils/constants'; + +export type FileUploaderProps = { + handleFileChange: ( + event: ChangeEvent | { target: { files: FileList } } + ) => void; +}; + +/** + * FileUploader component allows users to upload image files via drag-and-drop or file selection. + * + * This component manages the UI for adding background effects. + * @param {FileUploaderProps} props - The props for the component. + * @property {Function} handleFileChange - Callback function to handle background image change. + * @returns {ReactElement} The add background effect layout component. + */ +const FileUploader = ({ handleFileChange }: FileUploaderProps): ReactElement => { + const [dragOver, setDragOver] = useState(false); + + const onDragOver = (e: DragEvent) => { + e.preventDefault(); + setDragOver(true); + }; + + const onDragLeave = (e: DragEvent) => { + e.preventDefault(); + setDragOver(false); + }; + + const onDrop = (e: DragEvent) => { + e.preventDefault(); + setDragOver(false); + const { files } = e.dataTransfer; + if (files && files.length > 0) { + handleFileChange({ target: { files } }); + e.dataTransfer.clearData(); + } + }; + + return ( + + ); +}; + +export default FileUploader; diff --git a/frontend/src/components/BackgroundEffects/FileUploader/index.tsx b/frontend/src/components/BackgroundEffects/FileUploader/index.tsx new file mode 100644 index 00000000..9036fc89 --- /dev/null +++ b/frontend/src/components/BackgroundEffects/FileUploader/index.tsx @@ -0,0 +1,3 @@ +import FileUploader from './FileUploader'; + +export default FileUploader; diff --git a/frontend/src/components/BackgroundEffects/SelectableOption/SelectableOption.spec.tsx b/frontend/src/components/BackgroundEffects/SelectableOption/SelectableOption.spec.tsx index 798ff130..f35a0dfa 100644 --- a/frontend/src/components/BackgroundEffects/SelectableOption/SelectableOption.spec.tsx +++ b/frontend/src/components/BackgroundEffects/SelectableOption/SelectableOption.spec.tsx @@ -60,4 +60,32 @@ describe('SelectableOption', () => { const option = screen.getByTestId('background-disabled'); expect(option).toHaveAttribute('aria-disabled', 'true'); }); + + it('shows the title in the tooltip', async () => { + render( + {}} + id="with-title" + icon={Icon} + title="My Tooltip Title" + /> + ); + await userEvent.hover(screen.getByTestId('background-with-title')); + expect(screen.getByLabelText('My Tooltip Title')).toBeInTheDocument(); + }); + + it('renders children inside the option', () => { + render( + {}} + id="with-children" + icon={Icon} + > + Child Content + + ); + expect(screen.getByTestId('child-content')).toBeInTheDocument(); + }); }); diff --git a/frontend/src/components/BackgroundEffects/SelectableOption/SelectableOption.tsx b/frontend/src/components/BackgroundEffects/SelectableOption/SelectableOption.tsx index 231512ae..67973420 100644 --- a/frontend/src/components/BackgroundEffects/SelectableOption/SelectableOption.tsx +++ b/frontend/src/components/BackgroundEffects/SelectableOption/SelectableOption.tsx @@ -1,5 +1,5 @@ import { ReactElement, ReactNode } from 'react'; -import { Paper } from '@mui/material'; +import { Box, Paper, Tooltip } from '@mui/material'; import { DEFAULT_SELECTABLE_OPTION_WIDTH } from '../../../utils/constants'; export type SelectableOptionProps = { @@ -7,9 +7,11 @@ export type SelectableOptionProps = { onClick: () => void; id: string; icon?: ReactNode; + title?: string; image?: string; size?: number; isDisabled?: boolean; + children?: ReactNode; }; /** @@ -21,9 +23,11 @@ export type SelectableOptionProps = { * @property {Function} onClick - Function to call when the option is clicked * @property {string} id - Unique identifier for the option * @property {ReactNode} icon - Icon to display in the option + * @property {string} title - Title to display in the option * @property {string} image - Image URL to display in the option * @property {number} size - Size of the option (default is DEFAULT_SELECTABLE_OPTION_WIDTH) * @property {boolean} isDisabled - Whether the option is disabled + * @property {ReactNode} children - Additional content to render inside the option * @returns {ReactElement} A selectable option element */ const SelectableOption = ({ @@ -31,44 +35,70 @@ const SelectableOption = ({ onClick, id, icon, + title, image, size = DEFAULT_SELECTABLE_OPTION_WIDTH, isDisabled = false, + children, ...otherProps // Used by MUI Tooltip }: SelectableOptionProps): ReactElement => { return ( - - {image ? ( - background - ) : ( - icon - )} - + + + + {image ? ( + background + ) : ( + icon + )} + + + {children} + + ); }; diff --git a/frontend/src/components/MeetingRoom/BackgroundEffectsLayout/BackgroundEffectsLayout.tsx b/frontend/src/components/MeetingRoom/BackgroundEffectsLayout/BackgroundEffectsLayout.tsx deleted file mode 100644 index 0d5bed1a..00000000 --- a/frontend/src/components/MeetingRoom/BackgroundEffectsLayout/BackgroundEffectsLayout.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import { ReactElement, useCallback, useEffect, useState } from 'react'; -import { Box, Button, Typography } from '@mui/material'; -import { useTranslation } from 'react-i18next'; -import usePublisherContext from '../../../hooks/usePublisherContext'; -import RightPanelTitle from '../RightPanel/RightPanelTitle'; -import EffectOptionButtons from '../../BackgroundEffects/EffectOptionButtons/EffectOptionButtons'; -import BackgroundGallery from '../../BackgroundEffects/BackgroundGallery/BackgroundGallery'; -import BackgroundVideoContainer from '../../BackgroundEffects/BackgroundVideoContainer'; -import useBackgroundPublisherContext from '../../../hooks/useBackgroundPublisherContext'; -import { DEFAULT_SELECTABLE_OPTION_WIDTH } from '../../../utils/constants'; -import AddBackgroundEffect from '../../BackgroundEffects/AddBackgroundEffect/AddBackgroundEffect'; -import getInitialBackgroundFilter from '../../../utils/backgroundFilter/getInitialBackgroundFilter/getInitialBackgroundFilter'; - -export type BackgroundEffectsLayoutProps = { - handleClose: () => void; - isOpen: boolean; -}; - -/** - * BackgroundEffectsLayout Component - * - * This component manages the UI for background effects (cancel background and blurs) in a room. - * @param {BackgroundEffectsLayoutProps} props - The props for the component. - * @property {boolean} isOpen - Whether the background effects panel is open. - * @property {Function} handleClose - Function to close the panel. - * @returns {ReactElement} The background effects panel component. - */ -const BackgroundEffectsLayout = ({ - handleClose, - isOpen, -}: BackgroundEffectsLayoutProps): ReactElement | false => { - const [backgroundSelected, setBackgroundSelected] = useState('none'); - const { publisher, changeBackground, isVideoEnabled } = usePublisherContext(); - const { publisherVideoElement, changeBackground: changeBackgroundPreview } = - useBackgroundPublisherContext(); - - const handleBackgroundSelect = (selectedBackgroundOption: string) => { - setBackgroundSelected(selectedBackgroundOption); - changeBackgroundPreview(selectedBackgroundOption); - }; - - const handleApplyBackgroundSelect = async () => { - changeBackground(backgroundSelected); - handleClose(); - }; - - const setInitialBackgroundReplacement = useCallback(() => { - const selectedBackgroundOption = getInitialBackgroundFilter(publisher); - setBackgroundSelected(selectedBackgroundOption); - return selectedBackgroundOption; - }, [publisher, setBackgroundSelected]); - - const publisherVideoFilter = publisher?.getVideoFilter(); - const { t } = useTranslation(); - - // Reset background when closing the panel - useEffect(() => { - if (isOpen) { - const currentOption = setInitialBackgroundReplacement(); - changeBackgroundPreview(currentOption); - } - }, [publisherVideoFilter, isOpen, changeBackgroundPreview, setInitialBackgroundReplacement]); - - return ( - isOpen && ( - <> - - - - - - - - - {t('backgroundEffects.choice')} - - - - - - {/* TODO: load custom images */} - - - - - - - - - - ) - ); -}; - -export default BackgroundEffectsLayout; diff --git a/frontend/src/components/MeetingRoom/DeviceSettingsMenu/DeviceSettingsMenu.tsx b/frontend/src/components/MeetingRoom/DeviceSettingsMenu/DeviceSettingsMenu.tsx index a3eac284..0ca4d46c 100644 --- a/frontend/src/components/MeetingRoom/DeviceSettingsMenu/DeviceSettingsMenu.tsx +++ b/frontend/src/components/MeetingRoom/DeviceSettingsMenu/DeviceSettingsMenu.tsx @@ -55,6 +55,11 @@ const DeviceSettingsMenu = ({ const theme = useTheme(); const customLightBlueColor = 'rgb(138, 180, 248)'; + const handleToggleBackgroundEffects = () => { + toggleBackgroundEffects(); + handleToggle(); + }; + useDropdownResizeObserver({ setIsOpen, dropDownRefElement: anchorRef.current }); const renderSettingsMenu = () => { @@ -74,7 +79,7 @@ const DeviceSettingsMenu = ({ {hasMediaProcessorSupport() && ( <> - + )} diff --git a/frontend/src/components/MeetingRoom/RightPanel/RightPanel.tsx b/frontend/src/components/MeetingRoom/RightPanel/RightPanel.tsx index 6424554b..830a0d50 100644 --- a/frontend/src/components/MeetingRoom/RightPanel/RightPanel.tsx +++ b/frontend/src/components/MeetingRoom/RightPanel/RightPanel.tsx @@ -4,7 +4,7 @@ import Chat from '../Chat'; import ReportIssue from '../ReportIssue'; import type { RightPanelActiveTab } from '../../../hooks/useRightPanel'; import useIsSmallViewport from '../../../hooks/useIsSmallViewport'; -import BackgroundEffectsLayout from '../BackgroundEffectsLayout/BackgroundEffectsLayout'; +import BackgroundEffectsLayout from '../../BackgroundEffects/BackgroundEffectsLayout'; export type RightPanelProps = { handleClose: () => void; @@ -33,6 +33,7 @@ const RightPanel = ({ activeTab, handleClose }: RightPanelProps): ReactElement =
diff --git a/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsDialog/BackgroundEffectsDialog.tsx b/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsDialog/BackgroundEffectsDialog.tsx index b19327c5..88ae4fd7 100644 --- a/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsDialog/BackgroundEffectsDialog.tsx +++ b/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsDialog/BackgroundEffectsDialog.tsx @@ -1,6 +1,7 @@ -import { Dialog, DialogContent } from '@mui/material'; +import { Dialog, DialogContent, DialogTitle, IconButton } from '@mui/material'; +import CloseIcon from '@mui/icons-material/Close'; import { ReactElement } from 'react'; -import BackgroundEffectsLayout from '../BackgroundEffectsLayout/BackgroundEffectsLayout'; +import BackgroundEffectsLayout from '../../../BackgroundEffects/BackgroundEffectsLayout'; export type BackgroundEffectsDialogProps = { isBackgroundEffectsOpen: boolean; @@ -20,17 +21,32 @@ const BackgroundEffectsDialog = ({ isBackgroundEffectsOpen, setIsBackgroundEffectsOpen, }: BackgroundEffectsDialogProps): ReactElement | false => { + const handleClose = () => { + setIsBackgroundEffectsOpen(false); + }; + return ( - setIsBackgroundEffectsOpen(false)} - maxWidth="lg" - fullWidth - > + + + Background Effects + theme.palette.grey[500], + }} + > + + + setIsBackgroundEffectsOpen(false)} + handleClose={handleClose} /> diff --git a/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsLayout/BackgroundEffectsLayout.spec.tsx b/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsLayout/BackgroundEffectsLayout.spec.tsx deleted file mode 100644 index 0d87ed88..00000000 --- a/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsLayout/BackgroundEffectsLayout.spec.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import BackgroundEffectsLayout from './BackgroundEffectsLayout'; - -const mockChangeBackground = vi.fn(); - -vi.mock('../../../../hooks/usePreviewPublisherContext', () => ({ - __esModule: true, - default: () => ({ - publisher: { - getVideoFilter: vi.fn(() => undefined), - }, - changeBackground: mockChangeBackground, - isVideoEnabled: true, - }), -})); -vi.mock('../../../../hooks/useBackgroundPublisherContext', () => ({ - __esModule: true, - default: () => ({ - publisherVideoElement: null, - changeBackground: vi.fn(), - }), -})); - -describe('BackgroundEffects (Waiting Room)', () => { - const handleClose = vi.fn(); - const renderLayout = (isOpen = true) => - render(); - - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('renders when open', () => { - renderLayout(); - expect(screen.getByText('Background Effects')).toBeInTheDocument(); - expect(screen.getByTestId('background-video-container')).toBeInTheDocument(); - expect(screen.getByTestId('background-none')).toBeInTheDocument(); - expect(screen.getByTestId('background-upload')).toBeInTheDocument(); - expect(screen.getByTestId('background-bg1')).toBeInTheDocument(); - expect(screen.getAllByText(/Choose Background Effect/i)[0]).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /Cancel/i })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /Apply/i })).toBeInTheDocument(); - }); - - it('does not render when closed', () => { - const { container } = renderLayout(false); - expect(container).toBeEmptyDOMElement(); - }); - - it('calls handleClose when Cancel is clicked', async () => { - renderLayout(); - await userEvent.click(screen.getByRole('button', { name: /Cancel/i })); - expect(handleClose).toHaveBeenCalled(); - }); - - it('calls handleClose and changeBackground when Apply is clicked', async () => { - renderLayout(); - await userEvent.click(screen.getByRole('button', { name: /Apply/i })); - expect(mockChangeBackground).toHaveBeenCalled(); - expect(handleClose).toHaveBeenCalled(); - }); - - it('calls setBackgroundSelected when effect option none is clicked', async () => { - renderLayout(); - await userEvent.click(screen.getByTestId('background-none')); - }); - - it('calls setBackgroundSelected when a background gallery option is clicked', async () => { - renderLayout(); - await userEvent.click(screen.getByTestId('background-bg8')); - }); -}); diff --git a/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsLayout/BackgroundEffectsLayout.tsx b/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsLayout/BackgroundEffectsLayout.tsx deleted file mode 100644 index 9e0bc4e4..00000000 --- a/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsLayout/BackgroundEffectsLayout.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { ReactElement, useCallback, useEffect, useState } from 'react'; -import { Box, Button, Typography, useMediaQuery } from '@mui/material'; -import { useTranslation } from 'react-i18next'; -import EffectOptionButtons from '../../../BackgroundEffects/EffectOptionButtons/EffectOptionButtons'; -import BackgroundGallery from '../../../BackgroundEffects/BackgroundGallery/BackgroundGallery'; -import BackgroundVideoContainer from '../../../BackgroundEffects/BackgroundVideoContainer'; -import usePreviewPublisherContext from '../../../../hooks/usePreviewPublisherContext'; -import useBackgroundPublisherContext from '../../../../hooks/useBackgroundPublisherContext'; -import { DEFAULT_SELECTABLE_OPTION_WIDTH } from '../../../../utils/constants'; -import AddBackgroundEffect from '../../../BackgroundEffects/AddBackgroundEffect/AddBackgroundEffect'; -import getInitialBackgroundFilter from '../../../../utils/backgroundFilter/getInitialBackgroundFilter/getInitialBackgroundFilter'; - -export type BackgroundEffectsProps = { - isOpen: boolean; - handleClose: () => void; -}; - -/** - * BackgroundEffectsLayout Component - * - * This component manages the UI for background effects in the waiting room. - * @param {BackgroundEffectsProps} props - The props for the component. - * @property {boolean} isOpen - Whether the background effects panel is open. - * @property {Function} handleClose - Function to close the panel. - * @returns {ReactElement} The background effects panel component. - */ -const BackgroundEffectsLayout = ({ - isOpen, - handleClose, -}: BackgroundEffectsProps): ReactElement | false => { - const { t } = useTranslation(); - const [backgroundSelected, setBackgroundSelected] = useState('none'); - const { publisher, changeBackground, isVideoEnabled } = usePreviewPublisherContext(); - const { publisherVideoElement, changeBackground: changeBackgroundPreview } = - useBackgroundPublisherContext(); - const isTabletViewport = useMediaQuery(`(max-width:899px)`); - - const handleBackgroundSelect = (selectedBackgroundOption: string) => { - setBackgroundSelected(selectedBackgroundOption); - changeBackgroundPreview(selectedBackgroundOption); - }; - - const handleApplyBackgroundSelect = () => { - changeBackground(backgroundSelected); - handleClose(); - }; - - const setInitialBackgroundReplacement = useCallback(() => { - const selectedBackgroundOption = getInitialBackgroundFilter(publisher); - setBackgroundSelected(selectedBackgroundOption); - return selectedBackgroundOption; - }, [publisher, setBackgroundSelected]); - - // Reset background when closing the panel - useEffect(() => { - if (isOpen) { - const currentOption = setInitialBackgroundReplacement(); - changeBackgroundPreview(currentOption); - } - }, [publisher, isOpen, changeBackgroundPreview, setInitialBackgroundReplacement]); - - const buttonGroup = ( - - - - - ); - - return ( - isOpen && ( - <> - - {t('backgroundEffects.title')} - - - - - - {!isTabletViewport && buttonGroup} - - - - - {t('backgroundEffects.choice')} - - - - - {/* TODO: load custom images */} - - - - {isTabletViewport && buttonGroup} - - - ) - ); -}; - -export default BackgroundEffectsLayout; diff --git a/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsLayout/Index.tsx b/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsLayout/Index.tsx deleted file mode 100644 index cdeb11f9..00000000 --- a/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsLayout/Index.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import BackgroundEffectsLayout from './BackgroundEffectsLayout'; - -export default BackgroundEffectsLayout; diff --git a/frontend/src/css/App.css b/frontend/src/css/App.css index 4526b27b..878b4043 100644 --- a/frontend/src/css/App.css +++ b/frontend/src/css/App.css @@ -71,11 +71,64 @@ } .choose-background-effect-box { + display: flex; + max-height: 84%; + margin: 0 0.5rem; + overflow: hidden; border: 1px solid #e0e0e0; border-radius: 12px; - padding: 1rem; - padding-bottom: 0.5rem; + padding: 0.5rem; background-color: #f0f4f9; + justify-content: center; +} + +.choose-background-effect-grid { + width: 100%; + max-height: 100%; + overflow-y: auto; + padding: 8px; + min-height: 0; +} + +.choose-background-effect-grid-waiting { + width: 100%; + overflow-y: auto; + padding: 8px; + max-height: 35vh; +} +@media (min-width: 899px) and (min-height: 920px) { + .choose-background-effect-grid-waiting { + max-height: 55vh; + } +} +@media (max-height: 875px) { + .choose-background-effect-grid-waiting { + max-height: 30vh; + } +} +@media (max-height: 750px) { + .choose-background-effect-grid-waiting { + max-height: 25vh; + } +} +@media (max-width: 600px) { + .choose-background-effect-grid-waiting { + max-height: 25vh; + } +} +@media (max-width: 425px) { + .choose-background-effect-grid-waiting { + max-height: 32vh; + } +} +@media (max-height: 680px) { + .choose-background-effect-grid-waiting { + max-height: 30vh; + } +} + +.background-video-container { + margin-top: 8px; } .background-video-container-disabled { @@ -92,4 +145,39 @@ font-size: 1.25rem; margin: 0 auto 16px auto; aspect-ratio: 16 / 9; + padding: 1rem; +} + +.add-background-effect-input .MuiInputBase-root { + color: #5f6368; +} + +.add-background-effect-input .MuiOutlinedInput-root .MuiOutlinedInput-notchedOutline { + border-color: #80868b; +} + +.add-background-effect-input .MuiOutlinedInput-root:hover .MuiOutlinedInput-notchedOutline { + border-color: #80868b; +} + +.add-background-effect-input .MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline { + border-color: #80868b; +} + +.add-background-effect-input input::placeholder { + color: #5f6368; + opacity: 1; +} + +.add-background-effect-input-icon { + color: #5f6368; +} + +.file-upload-drop-area-text { + font-size: 1rem; +} +@media (max-width: 375px) and (max-height: 667px) { + .file-upload-drop-area-text { + font-size: 0.9rem !important; + } } diff --git a/frontend/src/hooks/tests/useIsTabletViewport.spec.tsx b/frontend/src/hooks/tests/useIsTabletViewport.spec.tsx new file mode 100644 index 00000000..2b4b0e13 --- /dev/null +++ b/frontend/src/hooks/tests/useIsTabletViewport.spec.tsx @@ -0,0 +1,45 @@ +import { renderHook } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi, afterAll } from 'vitest'; +import useIsTabletViewport from '../useIsTabletViewport'; +import { TABLET_VIEWPORT } from '../../utils/constants'; + +const matchMediaCommon = { + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), +}; + +describe('useIsTabletViewport', () => { + const originalMatchMedia = window.matchMedia; + + beforeEach(() => { + window.matchMedia = vi.fn().mockImplementation((query) => ({ + matches: new RegExp(`\\(max-width:\\s*${TABLET_VIEWPORT + 1}px\\)`).test(query), + media: query, + ...matchMediaCommon, + })); + }); + + afterAll(() => { + window.matchMedia = originalMatchMedia; + }); + + it('should return false when window width is greater than 899px', () => { + const { result } = renderHook(() => useIsTabletViewport()); + + expect(result.current).toBe(false); + }); + + it('should return true when window width is less than or equal to 899px', () => { + window.matchMedia = vi.fn().mockImplementation((query) => ({ + matches: new RegExp(`\\(max-width:\\s*${TABLET_VIEWPORT}px\\)`).test(query), + media: query, + ...matchMediaCommon, + })); + const { result } = renderHook(() => useIsTabletViewport()); + + expect(result.current).toBe(true); + }); +}); diff --git a/frontend/src/hooks/useIsTabletViewport.tsx b/frontend/src/hooks/useIsTabletViewport.tsx new file mode 100644 index 00000000..3ebb6e53 --- /dev/null +++ b/frontend/src/hooks/useIsTabletViewport.tsx @@ -0,0 +1,14 @@ +import { useMediaQuery } from '@mui/material'; +import { TABLET_VIEWPORT } from '../utils/constants'; + +/** + * useIsTabletViewport Hook + * + * A custom hook that checks if the viewport width is less than or equal to a defined tablet viewport width. + * @returns {boolean} True if the viewport is tablet-sized, false otherwise. + */ +const useIsTabletViewport = (): boolean => { + return useMediaQuery(`(max-width:${TABLET_VIEWPORT}px)`); +}; + +export default useIsTabletViewport; diff --git a/frontend/src/utils/backgroundFilter/applyBackgroundFilter/applyBackgroundFilter.spec.ts b/frontend/src/utils/backgroundFilter/applyBackgroundFilter/applyBackgroundFilter.spec.ts index c09ddc05..1ac8c524 100644 --- a/frontend/src/utils/backgroundFilter/applyBackgroundFilter/applyBackgroundFilter.spec.ts +++ b/frontend/src/utils/backgroundFilter/applyBackgroundFilter/applyBackgroundFilter.spec.ts @@ -126,4 +126,22 @@ describe('applyBackgroundFilter', () => { blurStrength: 'low', }); }); + + it('applies background replacement filter with a dataUrl', async () => { + const dataUrl = 'data:image/png;base64,somebase64data'; + await applyBackgroundFilter({ publisher: mockPublisher, backgroundSelected: dataUrl }); + + expect(mockPublisher.applyVideoFilter).toHaveBeenCalledWith({ + type: 'backgroundReplacement', + backgroundImgUrl: dataUrl, + }); + + expect(setStorageItem).toHaveBeenCalledWith( + STORAGE_KEYS.BACKGROUND_REPLACEMENT, + JSON.stringify({ + type: 'backgroundReplacement', + backgroundImgUrl: dataUrl, + }) + ); + }); }); diff --git a/frontend/src/utils/backgroundFilter/applyBackgroundFilter/applyBackgroundFilter.ts b/frontend/src/utils/backgroundFilter/applyBackgroundFilter/applyBackgroundFilter.ts index 8437602d..c778ab05 100644 --- a/frontend/src/utils/backgroundFilter/applyBackgroundFilter/applyBackgroundFilter.ts +++ b/frontend/src/utils/backgroundFilter/applyBackgroundFilter/applyBackgroundFilter.ts @@ -38,16 +38,21 @@ const applyBackgroundFilter = async ({ } let videoFilter: VideoFilter | undefined; + const isDataUrl = backgroundSelected.startsWith('data:image/'); + const isImageUrl = /^https?:\/\/.+\.(jpg|jpeg|png|gif|bmp)$/i.test(backgroundSelected); + const isImageFileName = /\.(jpg|jpeg|png|gif|bmp)$/i.test(backgroundSelected); + if (backgroundSelected === 'low-blur' || backgroundSelected === 'high-blur') { videoFilter = { type: 'backgroundBlur', blurStrength: backgroundSelected === 'low-blur' ? 'low' : 'high', }; await publisher.applyVideoFilter(videoFilter); - } else if (/\.(jpg|jpeg|png|gif|bmp)$/i.test(backgroundSelected)) { + } else if (isDataUrl || isImageUrl || isImageFileName) { videoFilter = { type: 'backgroundReplacement', - backgroundImgUrl: `${BACKGROUNDS_PATH}/${backgroundSelected}`, + backgroundImgUrl: + isDataUrl || isImageUrl ? backgroundSelected : `${BACKGROUNDS_PATH}/${backgroundSelected}`, }; await publisher.applyVideoFilter(videoFilter); } else { diff --git a/frontend/src/utils/backgroundFilter/getInitialBackgroundFilter/getInitialBackgroundFilter.spec.ts b/frontend/src/utils/backgroundFilter/getInitialBackgroundFilter/getInitialBackgroundFilter.spec.ts index 9bff2d68..89171c17 100644 --- a/frontend/src/utils/backgroundFilter/getInitialBackgroundFilter/getInitialBackgroundFilter.spec.ts +++ b/frontend/src/utils/backgroundFilter/getInitialBackgroundFilter/getInitialBackgroundFilter.spec.ts @@ -63,4 +63,25 @@ describe('getInitialBackgroundFilter', () => { } as unknown as Publisher; expect(getInitialBackgroundFilter(mockPublisher)).toBe('none'); }); + + it('returns dataUrl if filter is backgroundReplacement with a dataUrl', () => { + const dataUrl = 'data:image/png;base64,somebase64data'; + const mockPublisher = { + getVideoFilter: () => ({ + type: 'backgroundReplacement', + backgroundImgUrl: dataUrl, + }), + } as unknown as Publisher; + expect(getInitialBackgroundFilter(mockPublisher)).toBe(dataUrl); + }); + + it('returns "none" if filter is backgroundReplacement and backgroundImgUrl is undefined', () => { + const mockPublisher = { + getVideoFilter: () => ({ + type: 'backgroundReplacement', + backgroundImgUrl: undefined, + }), + } as unknown as Publisher; + expect(getInitialBackgroundFilter(mockPublisher)).toBe('none'); + }); }); diff --git a/frontend/src/utils/backgroundFilter/getInitialBackgroundFilter/getInitialBackgroundFilter.ts b/frontend/src/utils/backgroundFilter/getInitialBackgroundFilter/getInitialBackgroundFilter.ts index 7f0ec81b..e1cb9fae 100644 --- a/frontend/src/utils/backgroundFilter/getInitialBackgroundFilter/getInitialBackgroundFilter.ts +++ b/frontend/src/utils/backgroundFilter/getInitialBackgroundFilter/getInitialBackgroundFilter.ts @@ -4,7 +4,7 @@ import { Publisher } from '@vonage/client-sdk-video'; * Returns the initial background replacement setting based on the publisher's video filter. * @param {Publisher} publisher - The Vonage Publisher instance. * @returns {string} - The initial background replacement setting. - * Possible values are 'none', 'low-blur', 'high-blur', or the filename of a background image. + * Possible values are 'none', 'low-blur', 'high-blur', a base64 image or the filename of a background image. * If no valid background is set, it returns 'none'. * @throws {Error} - Throws an error if the publisher is not provided. */ @@ -19,7 +19,17 @@ const getInitialBackgroundFilter = (publisher?: Publisher | null): string => { } } if (filter?.type === 'backgroundReplacement') { - return filter.backgroundImgUrl?.split('/').pop() || 'none'; + const url = filter.backgroundImgUrl; + + if (!url) { + return 'none'; + } + + if (url.startsWith('data:image/')) { + return url; + } + + return url.split('/').pop() || 'none'; } return 'none'; }; diff --git a/frontend/src/utils/constants.tsx b/frontend/src/utils/constants.tsx index a7dfe1f1..1c119a7d 100644 --- a/frontend/src/utils/constants.tsx +++ b/frontend/src/utils/constants.tsx @@ -114,11 +114,35 @@ export const CAPTION_ERROR_DISPLAY_DURATION_MS = 4000; */ export const SMALL_VIEWPORT = 768; +/* + * @constant {number} TABLET_VIEWPORT - The pixel width threshold used to determine if the viewport is considered tablet. + * Typically used as the max-width breakpoint for responsive layouts. + */ +export const TABLET_VIEWPORT = 899; + /** * @constant {string} BACKGROUNDS_PATH - The path to the backgrounds assets directory. */ export const BACKGROUNDS_PATH = '/background'; +/** + * @constant {number} MAX_SIZE_MB - The maximum file size (in megabytes) allowed for image uploads. + * Used to validate image uploads in components like AddBackgroundEffectLayout. + */ +export const MAX_SIZE_MB = 2; + +/** + * @constant {string[]} ALLOWED_TYPES - An array of allowed MIME types for image uploads. + * Used to validate image uploads in components like AddBackgroundEffectLayout. + */ +export const ALLOWED_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/bmp']; + +/** + * @constant {number} MAX_LOCAL_STORAGE_BYTES - The maximum size (in bytes) for storing images in localStorage. + * This is set to approximately 4MB, which is a common limit for localStorage across browsers. + */ +export const MAX_LOCAL_STORAGE_BYTES = 4 * 1024 * 1024; + /** * @constant {number} DEFAULT_SELECTABLE_OPTION_WIDTH - The default size (in pixels) for selectable option elements. * Used to define the width of selectable options in UI components. diff --git a/frontend/src/utils/storage.ts b/frontend/src/utils/storage.ts index c6b93305..bd0c76f7 100644 --- a/frontend/src/utils/storage.ts +++ b/frontend/src/utils/storage.ts @@ -4,6 +4,7 @@ export const STORAGE_KEYS = { NOISE_SUPPRESSION: 'noiseSuppression', BACKGROUND_REPLACEMENT: 'backgroundReplacement', USERNAME: 'username', + BACKGROUND_IMAGE: 'userBackgroundImage', }; export const setStorageItem = (key: string, value: string) => { diff --git a/frontend/src/utils/useImageStorage/useImageStorage.spec.ts b/frontend/src/utils/useImageStorage/useImageStorage.spec.ts new file mode 100644 index 00000000..6ec1bbdf --- /dev/null +++ b/frontend/src/utils/useImageStorage/useImageStorage.spec.ts @@ -0,0 +1,102 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import useImageStorage, { StoredImage } from './useImageStorage'; +import { STORAGE_KEYS } from '../storage'; + +const mockStorage: Record = {}; + +vi.mock('../storage', async () => { + const actual = await vi.importActual('../storage'); + return { + ...actual, + getStorageItem: vi.fn((key: string) => mockStorage[key] || null), + setStorageItem: vi.fn((key: string, value: string) => { + mockStorage[key] = value; + }), + STORAGE_KEYS: { + BACKGROUND_IMAGE: 'BACKGROUND_IMAGE', + }, + }; +}); + +const mockDataUrl = 'data:image/png;base64,mockdata'; +const mockImage: StoredImage = { id: '1', dataUrl: mockDataUrl }; + +describe('useImageStorage', () => { + beforeEach(() => { + Object.keys(mockStorage).forEach((key) => delete mockStorage[key]); + }); + + it('returns empty array if no data in storage', () => { + const { result } = renderHook(() => useImageStorage()); + expect(result.current.getImagesFromStorage()).toEqual([]); + }); + + it('returns parsed data if valid JSON in storage', () => { + mockStorage[STORAGE_KEYS.BACKGROUND_IMAGE] = JSON.stringify([mockImage]); + const { result } = renderHook(() => useImageStorage()); + expect(result.current.getImagesFromStorage()).toEqual([mockImage]); + }); + + it('deletes an image by id', () => { + mockStorage[STORAGE_KEYS.BACKGROUND_IMAGE] = JSON.stringify([ + { id: '1', dataUrl: 'abc' }, + { id: '2', dataUrl: 'def' }, + ]); + const { result } = renderHook(() => useImageStorage()); + act(() => { + result.current.deleteImageFromStorage('1'); + }); + + const newData = JSON.parse(mockStorage[STORAGE_KEYS.BACKGROUND_IMAGE]); + expect(newData).toHaveLength(1); + expect(newData[0].id).toBe('2'); + }); + + it('rejects invalid URL in handleImageFromLink', async () => { + const { result } = renderHook(() => useImageStorage()); + + await act(async () => { + await expect(result.current.handleImageFromLink('invalid-url')).rejects.toBeUndefined(); + }); + + await waitFor(() => expect(result.current.storageError).toBe('Invalid image URL.')); + }); + + it('rejects invalid extension in handleImageFromLink', async () => { + const { result } = renderHook(() => useImageStorage()); + + await act(async () => { + await expect( + result.current.handleImageFromLink('https://example.com/file.txt') + ).rejects.toBeUndefined(); + }); + + await waitFor(() => expect(result.current.storageError).toBe('Invalid image extension.')); + }); + + it('processes a valid file in handleImageFromFile', async () => { + const { result } = renderHook(() => useImageStorage()); + const file = new File(['file-content'], 'test.png', { type: 'image/png' }); + + await act(async () => { + const stored = await result.current.handleImageFromFile(file); + expect(stored?.dataUrl).toContain('data:image'); + }); + }); + + it('limits image size for localStorage (~4MB)', async () => { + const largeDataUrl = `data:image/png;base64,${'a'.repeat(5 * 1024 * 1024)}`; + const largeImage: StoredImage = { id: 'x', dataUrl: largeDataUrl }; + const { result } = renderHook(() => useImageStorage()); + + act(() => { + const success = result.current.saveImagesToStorage([largeImage]); + expect(success).toBe(false); + }); + + await waitFor(() => + expect(result.current.storageError).toBe('Images are too large to store (~4MB max).') + ); + }); +}); diff --git a/frontend/src/utils/useImageStorage/useImageStorage.ts b/frontend/src/utils/useImageStorage/useImageStorage.ts new file mode 100644 index 00000000..c854d383 --- /dev/null +++ b/frontend/src/utils/useImageStorage/useImageStorage.ts @@ -0,0 +1,180 @@ +import { useCallback, useState } from 'react'; +import { getStorageItem, setStorageItem, STORAGE_KEYS } from '../storage'; +import { MAX_LOCAL_STORAGE_BYTES } from '../constants'; + +export type StoredImage = { + id: string; + dataUrl: string; +}; + +/** + * Custom hook for managing image storage in localStorage. + * @returns {object} - The image storage methods and error state. + */ +const useImageStorage = () => { + const [storageError, setStorageError] = useState(''); + + // Estimate size of a string in bytes + const estimateSizeInBytes = (str: string) => new Blob([str]).size; + + /** + * Retrieves stored images from localStorage. + * @returns {StoredImage[]} An array of stored images. + */ + const getImagesFromStorage = useCallback((): StoredImage[] => { + const stored = getStorageItem(STORAGE_KEYS.BACKGROUND_IMAGE); + if (!stored) { + return []; + } + try { + return JSON.parse(stored) as StoredImage[]; + } catch { + return []; + } + }, []); + + /** + * Saves an array of images to localStorage. + * @param {StoredImage[]} images - The array of images to save. + * @returns {boolean} True if save was successful, false otherwise. + */ + const saveImagesToStorage = (images: StoredImage[]): boolean => { + try { + const totalSize = images.reduce((acc, img) => acc + estimateSizeInBytes(img.dataUrl), 0); + if (totalSize > MAX_LOCAL_STORAGE_BYTES) { + setStorageError('Images are too large to store (~4MB max).'); + return false; + } + setStorageItem(STORAGE_KEYS.BACKGROUND_IMAGE, JSON.stringify(images)); + setStorageError(''); + return true; + } catch { + setStorageError('Failed to store images in localStorage.'); + return false; + } + }; + + /** + * Adds an image to storage. + * @param {string} dataUrl - The data URL of the image to add. + * @returns {StoredImage | null} The added image object, or null if duplicate or error. + */ + const addImageToStorage = (dataUrl: string): StoredImage | null => { + const images = getImagesFromStorage(); + + const isDuplicate = images.some((img) => img.dataUrl === dataUrl); + if (isDuplicate) { + setStorageError('This image is already added.'); + return null; + } + + const generateId = (): string => { + if (typeof crypto !== 'undefined' && crypto.randomUUID) { + return crypto.randomUUID(); + } + + const array = new Uint32Array(4); + window.crypto.getRandomValues(array); + return Array.from(array, (n) => n.toString(16)).join(''); + }; + + const newImage: StoredImage = { + id: generateId(), + dataUrl, + }; + images.push(newImage); + const success = saveImagesToStorage(images); + return success ? newImage : null; + }; + + const deleteImageFromStorage = (id: string) => { + const images = getImagesFromStorage().filter((img) => img.id !== id); + saveImagesToStorage(images); + }; + + /** + * Reads an image file and adds it to storage as a data URL. + * @param {File} file - The image file to store. + * @returns {Promise} Resolves with the stored image object, or null if duplicate or error. + */ + const handleImageFromFile = (file: File): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const dataUrl = reader.result as string; + const newImage = addImageToStorage(dataUrl); + if (newImage) { + resolve(newImage); + } else { + resolve(null); + } + }; + reader.onerror = () => { + setStorageError('Failed to read image file.'); + reject(); + }; + reader.readAsDataURL(file); + }); + }; + + /** + * Loads an image from a URL, converts it to a data URL, and adds it to storage. + * @param {string} url - The image URL to fetch and store. + * @returns {Promise} Resolves with the stored image object, or rejects on error. + */ + const handleImageFromLink = (url: string): Promise => { + return new Promise((resolve, reject) => { + try { + const parsed = new URL(url); + const validExt = /\.(jpg|jpeg|png|gif|bmp)$/i.test(parsed.pathname); + if (!validExt) { + setStorageError('Invalid image extension.'); + reject(); + return; + } + } catch { + setStorageError('Invalid image URL.'); + reject(); + return; + } + + const img = new Image(); + img.crossOrigin = 'Anonymous'; + img.onload = () => { + try { + const canvas = document.createElement('canvas'); + canvas.width = img.width; + canvas.height = img.height; + const ctx = canvas.getContext('2d'); + ctx?.drawImage(img, 0, 0); + const dataUrl = canvas.toDataURL('image/png'); + const newImage = addImageToStorage(dataUrl); + if (newImage) { + resolve(newImage); + } else { + reject(); + } + } catch { + setStorageError('Could not convert image.'); + reject(); + } + }; + img.onerror = () => { + setStorageError('Could not load image.'); + reject(); + }; + img.src = url; + }); + }; + + return { + storageError, + handleImageFromFile, + handleImageFromLink, + getImagesFromStorage, + deleteImageFromStorage, + saveImagesToStorage, + }; +}; + +export default useImageStorage; diff --git a/yarn.lock b/yarn.lock index dd828af0..895a1735 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1786,105 +1786,105 @@ estree-walker "^2.0.2" picomatch "^2.3.1" -"@rollup/rollup-android-arm-eabi@4.43.0": - version "4.43.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.43.0.tgz#9241b59af721beb7e3587a56c6c245d6c465753d" - integrity sha512-Krjy9awJl6rKbruhQDgivNbD1WuLb8xAclM4IR4cN5pHGAs2oIMMQJEiC3IC/9TZJ+QZkmZhlMO/6MBGxPidpw== - -"@rollup/rollup-android-arm64@4.43.0": - version "4.43.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.43.0.tgz#f70ee53ba991fdd65c277b0716c559736d490a58" - integrity sha512-ss4YJwRt5I63454Rpj+mXCXicakdFmKnUNxr1dLK+5rv5FJgAxnN7s31a5VchRYxCFWdmnDWKd0wbAdTr0J5EA== - -"@rollup/rollup-darwin-arm64@4.43.0": - version "4.43.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.43.0.tgz#9f59000e817cf5760d87515ce899f8b93fe8756a" - integrity sha512-eKoL8ykZ7zz8MjgBenEF2OoTNFAPFz1/lyJ5UmmFSz5jW+7XbH1+MAgCVHy72aG59rbuQLcJeiMrP8qP5d/N0A== - -"@rollup/rollup-darwin-x64@4.43.0": - version "4.43.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.43.0.tgz#c92aebd02725ae1b88bdce40f08f7823e8055c78" - integrity sha512-SYwXJgaBYW33Wi/q4ubN+ldWC4DzQY62S4Ll2dgfr/dbPoF50dlQwEaEHSKrQdSjC6oIe1WgzosoaNoHCdNuMg== - -"@rollup/rollup-freebsd-arm64@4.43.0": - version "4.43.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.43.0.tgz#b128dbe7b353922ddd729a4fc4e408ddcbf338b5" - integrity sha512-SV+U5sSo0yujrjzBF7/YidieK2iF6E7MdF6EbYxNz94lA+R0wKl3SiixGyG/9Klab6uNBIqsN7j4Y/Fya7wAjQ== - -"@rollup/rollup-freebsd-x64@4.43.0": - version "4.43.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.43.0.tgz#88297a0ddfadddd61d7d9b73eb42b3f227301d30" - integrity sha512-J7uCsiV13L/VOeHJBo5SjasKiGxJ0g+nQTrBkAsmQBIdil3KhPnSE9GnRon4ejX1XDdsmK/l30IYLiAaQEO0Cg== - -"@rollup/rollup-linux-arm-gnueabihf@4.43.0": - version "4.43.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.43.0.tgz#a59afc092523ebe43d3899f33da9cdd2ec01fb87" - integrity sha512-gTJ/JnnjCMc15uwB10TTATBEhK9meBIY+gXP4s0sHD1zHOaIh4Dmy1X9wup18IiY9tTNk5gJc4yx9ctj/fjrIw== - -"@rollup/rollup-linux-arm-musleabihf@4.43.0": - version "4.43.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.43.0.tgz#3095c1327b794bd187d03e372e633717fb69b4c0" - integrity sha512-ZJ3gZynL1LDSIvRfz0qXtTNs56n5DI2Mq+WACWZ7yGHFUEirHBRt7fyIk0NsCKhmRhn7WAcjgSkSVVxKlPNFFw== - -"@rollup/rollup-linux-arm64-gnu@4.43.0": - version "4.43.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.43.0.tgz#e43bb77df3a6de85312e991d1e3ad352d1abb00d" - integrity sha512-8FnkipasmOOSSlfucGYEu58U8cxEdhziKjPD2FIa0ONVMxvl/hmONtX/7y4vGjdUhjcTHlKlDhw3H9t98fPvyA== - -"@rollup/rollup-linux-arm64-musl@4.43.0": - version "4.43.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.43.0.tgz#34873a437bcd87618f702dc66f0cbce170aebf9f" - integrity sha512-KPPyAdlcIZ6S9C3S2cndXDkV0Bb1OSMsX0Eelr2Bay4EsF9yi9u9uzc9RniK3mcUGCLhWY9oLr6er80P5DE6XA== - -"@rollup/rollup-linux-loongarch64-gnu@4.43.0": - version "4.43.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.43.0.tgz#224ff524349e365baa56f1f512822548c2d76910" - integrity sha512-HPGDIH0/ZzAZjvtlXj6g+KDQ9ZMHfSP553za7o2Odegb/BEfwJcR0Sw0RLNpQ9nC6Gy8s+3mSS9xjZ0n3rhcYg== - -"@rollup/rollup-linux-powerpc64le-gnu@4.43.0": - version "4.43.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.43.0.tgz#43c3c053b26ace18a1d3dab204596a466c1b0e34" - integrity sha512-gEmwbOws4U4GLAJDhhtSPWPXUzDfMRedT3hFMyRAvM9Mrnj+dJIFIeL7otsv2WF3D7GrV0GIewW0y28dOYWkmw== - -"@rollup/rollup-linux-riscv64-gnu@4.43.0": - version "4.43.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.43.0.tgz#e7df825d71daefa7037605015455aa58be43cd7a" - integrity sha512-XXKvo2e+wFtXZF/9xoWohHg+MuRnvO29TI5Hqe9xwN5uN8NKUYy7tXUG3EZAlfchufNCTHNGjEx7uN78KsBo0g== - -"@rollup/rollup-linux-riscv64-musl@4.43.0": - version "4.43.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.43.0.tgz#d76ad93a7f4c0b2855a024d8d859196acf38acf5" - integrity sha512-ruf3hPWhjw6uDFsOAzmbNIvlXFXlBQ4nk57Sec8E8rUxs/AI4HD6xmiiasOOx/3QxS2f5eQMKTAwk7KHwpzr/Q== - -"@rollup/rollup-linux-s390x-gnu@4.43.0": - version "4.43.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.43.0.tgz#0852608843d05852af3f447bf43bb63d80d62b6a" - integrity sha512-QmNIAqDiEMEvFV15rsSnjoSmO0+eJLoKRD9EAa9rrYNwO/XRCtOGM3A5A0X+wmG+XRrw9Fxdsw+LnyYiZWWcVw== - -"@rollup/rollup-linux-x64-gnu@4.43.0": - version "4.43.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.43.0.tgz#d16a57f86357a4e697142bee244afed59b24e6c5" - integrity sha512-jAHr/S0iiBtFyzjhOkAics/2SrXE092qyqEg96e90L3t9Op8OTzS6+IX0Fy5wCt2+KqeHAkti+eitV0wvblEoQ== - -"@rollup/rollup-linux-x64-musl@4.43.0": - version "4.43.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.43.0.tgz#51cbc8b1eb46ebc0e284725418b6fbf48686e4e2" - integrity sha512-3yATWgdeXyuHtBhrLt98w+5fKurdqvs8B53LaoKD7P7H7FKOONLsBVMNl9ghPQZQuYcceV5CDyPfyfGpMWD9mQ== - -"@rollup/rollup-win32-arm64-msvc@4.43.0": - version "4.43.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.43.0.tgz#d6d84aace2b211119bf0ab1c586e29d01e32aa01" - integrity sha512-wVzXp2qDSCOpcBCT5WRWLmpJRIzv23valvcTwMHEobkjippNf+C3ys/+wf07poPkeNix0paTNemB2XrHr2TnGw== - -"@rollup/rollup-win32-ia32-msvc@4.43.0": - version "4.43.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.43.0.tgz#4af33168de2f65b97a8f36bd1d8d21cea34d3ccb" - integrity sha512-fYCTEyzf8d+7diCw8b+asvWDCLMjsCEA8alvtAutqJOJp/wL5hs1rWSqJ1vkjgW0L2NB4bsYJrpKkiIPRR9dvw== - -"@rollup/rollup-win32-x64-msvc@4.43.0": - version "4.43.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.43.0.tgz#42a88207659e404e8ffa655cae763cbad94906ab" - integrity sha512-SnGhLiE5rlK0ofq8kzuDkM0g7FN1s5VYY+YSMTibP7CqShxCQvqtNxTARS4xX4PFJfHjG0ZQYX9iGzI3FQh5Aw== +"@rollup/rollup-android-arm-eabi@4.46.2": + version "4.46.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.46.2.tgz#292e25953d4988d3bd1af0f5ebbd5ee4d65c90b4" + integrity sha512-Zj3Hl6sN34xJtMv7Anwb5Gu01yujyE/cLBDB2gnHTAHaWS1Z38L7kuSG+oAh0giZMqG060f/YBStXtMH6FvPMA== + +"@rollup/rollup-android-arm64@4.46.2": + version "4.46.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.46.2.tgz#053b3def3451e6fc1a9078188f22799e868d7c59" + integrity sha512-nTeCWY83kN64oQ5MGz3CgtPx8NSOhC5lWtsjTs+8JAJNLcP3QbLCtDDgUKQc/Ro/frpMq4SHUaHN6AMltcEoLQ== + +"@rollup/rollup-darwin-arm64@4.46.2": + version "4.46.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.46.2.tgz#98d90445282dec54fd05440305a5e8df79a91ece" + integrity sha512-HV7bW2Fb/F5KPdM/9bApunQh68YVDU8sO8BvcW9OngQVN3HHHkw99wFupuUJfGR9pYLLAjcAOA6iO+evsbBaPQ== + +"@rollup/rollup-darwin-x64@4.46.2": + version "4.46.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.46.2.tgz#fe05f95a736423af5f9c3a59a70f41ece52a1f20" + integrity sha512-SSj8TlYV5nJixSsm/y3QXfhspSiLYP11zpfwp6G/YDXctf3Xkdnk4woJIF5VQe0of2OjzTt8EsxnJDCdHd2xMA== + +"@rollup/rollup-freebsd-arm64@4.46.2": + version "4.46.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.46.2.tgz#41e1fbdc1f8c3dc9afb6bc1d6e3fb3104bd81eee" + integrity sha512-ZyrsG4TIT9xnOlLsSSi9w/X29tCbK1yegE49RYm3tu3wF1L/B6LVMqnEWyDB26d9Ecx9zrmXCiPmIabVuLmNSg== + +"@rollup/rollup-freebsd-x64@4.46.2": + version "4.46.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.46.2.tgz#69131e69cb149d547abb65ef3b38fc746c940e24" + integrity sha512-pCgHFoOECwVCJ5GFq8+gR8SBKnMO+xe5UEqbemxBpCKYQddRQMgomv1104RnLSg7nNvgKy05sLsY51+OVRyiVw== + +"@rollup/rollup-linux-arm-gnueabihf@4.46.2": + version "4.46.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.46.2.tgz#977ded91c7cf6fc0d9443bb9c0a064e45a805267" + integrity sha512-EtP8aquZ0xQg0ETFcxUbU71MZlHaw9MChwrQzatiE8U/bvi5uv/oChExXC4mWhjiqK7azGJBqU0tt5H123SzVA== + +"@rollup/rollup-linux-arm-musleabihf@4.46.2": + version "4.46.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.46.2.tgz#dc034fc3c0f0eb5c75b6bc3eca3b0b97fd35f49a" + integrity sha512-qO7F7U3u1nfxYRPM8HqFtLd+raev2K137dsV08q/LRKRLEc7RsiDWihUnrINdsWQxPR9jqZ8DIIZ1zJJAm5PjQ== + +"@rollup/rollup-linux-arm64-gnu@4.46.2": + version "4.46.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.46.2.tgz#5e92613768d3de3ffcabc965627dd0a59b3e7dfc" + integrity sha512-3dRaqLfcOXYsfvw5xMrxAk9Lb1f395gkoBYzSFcc/scgRFptRXL9DOaDpMiehf9CO8ZDRJW2z45b6fpU5nwjng== + +"@rollup/rollup-linux-arm64-musl@4.46.2": + version "4.46.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.46.2.tgz#2a44f88e83d28b646591df6e50aa0a5a931833d8" + integrity sha512-fhHFTutA7SM+IrR6lIfiHskxmpmPTJUXpWIsBXpeEwNgZzZZSg/q4i6FU4J8qOGyJ0TR+wXBwx/L7Ho9z0+uDg== + +"@rollup/rollup-linux-loongarch64-gnu@4.46.2": + version "4.46.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.46.2.tgz#bd5897e92db7fbf7dc456f61d90fff96c4651f2e" + integrity sha512-i7wfGFXu8x4+FRqPymzjD+Hyav8l95UIZ773j7J7zRYc3Xsxy2wIn4x+llpunexXe6laaO72iEjeeGyUFmjKeA== + +"@rollup/rollup-linux-ppc64-gnu@4.46.2": + version "4.46.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.46.2.tgz#a7065025411c14ad9ec34cc1cd1414900ec2a303" + integrity sha512-B/l0dFcHVUnqcGZWKcWBSV2PF01YUt0Rvlurci5P+neqY/yMKchGU8ullZvIv5e8Y1C6wOn+U03mrDylP5q9Yw== + +"@rollup/rollup-linux-riscv64-gnu@4.46.2": + version "4.46.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.46.2.tgz#17f9c0c675e13ef4567cfaa3730752417257ccc3" + integrity sha512-32k4ENb5ygtkMwPMucAb8MtV8olkPT03oiTxJbgkJa7lJ7dZMr0GCFJlyvy+K8iq7F/iuOr41ZdUHaOiqyR3iQ== + +"@rollup/rollup-linux-riscv64-musl@4.46.2": + version "4.46.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.46.2.tgz#bc6ed3db2cedc1ba9c0a2183620fe2f792c3bf3f" + integrity sha512-t5B2loThlFEauloaQkZg9gxV05BYeITLvLkWOkRXogP4qHXLkWSbSHKM9S6H1schf/0YGP/qNKtiISlxvfmmZw== + +"@rollup/rollup-linux-s390x-gnu@4.46.2": + version "4.46.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.46.2.tgz#440c4f6753274e2928e06d2a25613e5a1cf97b41" + integrity sha512-YKjekwTEKgbB7n17gmODSmJVUIvj8CX7q5442/CK80L8nqOUbMtf8b01QkG3jOqyr1rotrAnW6B/qiHwfcuWQA== + +"@rollup/rollup-linux-x64-gnu@4.46.2": + version "4.46.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.46.2.tgz#1e936446f90b2574ea4a83b4842a762cc0a0aed3" + integrity sha512-Jj5a9RUoe5ra+MEyERkDKLwTXVu6s3aACP51nkfnK9wJTraCC8IMe3snOfALkrjTYd2G1ViE1hICj0fZ7ALBPA== + +"@rollup/rollup-linux-x64-musl@4.46.2": + version "4.46.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.46.2.tgz#c6f304dfba1d5faf2be5d8b153ccbd8b5d6f1166" + integrity sha512-7kX69DIrBeD7yNp4A5b81izs8BqoZkCIaxQaOpumcJ1S/kmqNFjPhDu1LHeVXv0SexfHQv5cqHsxLOjETuqDuA== + +"@rollup/rollup-win32-arm64-msvc@4.46.2": + version "4.46.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.46.2.tgz#b4ad4a79219892aac112ed1c9d1356cad0566ef5" + integrity sha512-wiJWMIpeaak/jsbaq2HMh/rzZxHVW1rU6coyeNNpMwk5isiPjSTx0a4YLSlYDwBH/WBvLz+EtsNqQScZTLJy3g== + +"@rollup/rollup-win32-ia32-msvc@4.46.2": + version "4.46.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.46.2.tgz#b1b22eb2a9568048961e4a6f540438b4a762aa62" + integrity sha512-gBgaUDESVzMgWZhcyjfs9QFK16D8K6QZpwAaVNJxYDLHWayOta4ZMjGm/vsAEy3hvlS2GosVFlBlP9/Wb85DqQ== + +"@rollup/rollup-win32-x64-msvc@4.46.2": + version "4.46.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.46.2.tgz#87079f137b5fdb75da11508419aa998cc8cc3d8b" + integrity sha512-CvUo2ixeIQGtF6WvuB87XWqPQkoFAFqW+HUo/WzHwuHDvIwZCtjdWXoYCcr06iKGydiqTclC4jU/TNObC/xKZg== "@shikijs/core@1.22.2": version "1.22.2"