diff --git a/apps/webapp/src/i18n/en-US.json b/apps/webapp/src/i18n/en-US.json index 2b8ffd1689d..d39f86499b6 100644 --- a/apps/webapp/src/i18n/en-US.json +++ b/apps/webapp/src/i18n/en-US.json @@ -1688,6 +1688,12 @@ "preferencesOptionsEmojiReplaceDetail": ":-) → [icon]", "preferencesOptionsEnableAgcCheckbox": "Automatic gain control (AGC)", "preferencesOptionsEnableAgcDetails": "Enable to allow your microphone volume to be adjusted automatically to ensure all participants in a call are heard with similar and comfortable loudness.", + "preferencesOptionsEnableHardwareAcceleration": "Hardware acceleration", + "preferencesOptionsEnableHardwareAccelerationDetails": "This improves stability and video performance while reducing CPU usage. If you change the setting, you need to restart the app.", + "preferencesOptionsEnableHardwareAccelerationModalCancel": "Cancel", + "preferencesOptionsEnableHardwareAccelerationModalMessage": "To update the hardware acceleration setting, you need to restart the app.", + "preferencesOptionsEnableHardwareAccelerationModalOk": "Restart App", + "preferencesOptionsEnableHardwareAccelerationModalTitle": "Restart app", "preferencesOptionsEnablePressSpaceToUnmute": "Unmute with space bar", "preferencesOptionsEnablePressSpaceToUnmuteDetails": "Enable to unmute your microphone by pressing and holding the space bar as long as you want to speak. You can use this option in full view.", "preferencesOptionsEnableSoundlessIncomingCalls": "Silence other calls", diff --git a/apps/webapp/src/script/Config.ts b/apps/webapp/src/script/Config.ts index 5993849ba92..0882e819f77 100644 --- a/apps/webapp/src/script/Config.ts +++ b/apps/webapp/src/script/Config.ts @@ -119,6 +119,13 @@ const Config = { return window.desktopAppConfig; }, + getDesktopSettings: () => { + if (!Runtime.isDesktopApp()) { + return undefined; + } + + return window.desktopAppSettings; + }, }; export {Config}; diff --git a/apps/webapp/src/script/page/MainContent/panels/preferences/avPreferences/CallOptions.tsx b/apps/webapp/src/script/page/MainContent/panels/preferences/avPreferences/CallOptions.tsx index d2ef1180d1c..42e9b0ddd03 100644 --- a/apps/webapp/src/script/page/MainContent/panels/preferences/avPreferences/CallOptions.tsx +++ b/apps/webapp/src/script/page/MainContent/panels/preferences/avPreferences/CallOptions.tsx @@ -22,9 +22,11 @@ import {ChangeEvent, useCallback, useEffect, useRef, useState} from 'react'; import type {WebappProperties} from '@wireapp/api-client/lib/user/data/'; import {amplify} from 'amplify'; +import {Runtime} from '@wireapp/commons'; import {Checkbox, CheckboxLabel} from '@wireapp/react-ui-kit'; import {WebAppEvents} from '@wireapp/webapp-events'; +import {PrimaryModal} from 'Components/Modals/PrimaryModal'; import type {MediaConstraintsHandler} from 'Repositories/media/MediaConstraintsHandler'; import type {PropertiesRepository} from 'Repositories/properties/PropertiesRepository'; import {PROPERTIES_TYPE} from 'Repositories/properties/PropertiesType'; @@ -54,6 +56,20 @@ const CallOptions = ({constraintsHandler, propertiesRepository}: CallOptionsProp !!propertiesRepository.properties.settings.call.enable_press_space_to_unmute, ); + const hardwareAccelerationDescriptionId = 'status-preference-hardware-acceleration-description'; + + const desktopSettings = Config.getDesktopSettings(); + + const isHardwareAccelerationChangeable = Runtime.isDesktopApp() && !!desktopSettings; + + const [isHardwareAccelerationEnabled, setIsHardwareAccelerationEnabled] = useState(() => { + if (!isHardwareAccelerationChangeable) { + return true; // default in browser (but not changeable) + } + + return desktopSettings.isHardwareAccelerationEnabled(); + }); + useEffect(() => { const updateProperties = ({settings}: WebappProperties) => { setVbrEncoding(!isCbrEncodingEnforced && settings.call.enable_vbr_encoding); @@ -102,6 +118,33 @@ const CallOptions = ({constraintsHandler, propertiesRepository}: CallOptionsProp [propertiesRepository], ); + const showHardwareAccelerationRestartModal = () => { + PrimaryModal.show(PrimaryModal.type.CONFIRM, { + size: 'large', + primaryAction: { + action: confirmHardwareAccelerationChange, + text: t('preferencesOptionsEnableHardwareAccelerationModalOk'), + }, + text: { + message: t('preferencesOptionsEnableHardwareAccelerationModalMessage'), + title: t('preferencesOptionsEnableHardwareAccelerationModalTitle'), + }, + }); + }; + + const confirmHardwareAccelerationChange = () => { + if (!desktopSettings) { + return; + } + + const currentHwValue = desktopSettings.isHardwareAccelerationEnabled(); + + desktopSettings.setHardwareAccelerationEnabled(!currentHwValue); + setIsHardwareAccelerationEnabled(!currentHwValue); + + amplify.publish(WebAppEvents.LIFECYCLE.RESTART); + }; + return (
@@ -153,6 +196,25 @@ const CallOptions = ({constraintsHandler, propertiesRepository}: CallOptionsProp

)} + + {isHardwareAccelerationChangeable && ( +
+ + + {t('preferencesOptionsEnableHardwareAcceleration')} + + +

+ {t('preferencesOptionsEnableHardwareAccelerationDetails')} +

+
+ )}
); }; diff --git a/apps/webapp/src/script/page/MainContent/panels/preferences/avPreferences/callOptions.test.tsx b/apps/webapp/src/script/page/MainContent/panels/preferences/avPreferences/callOptions.test.tsx new file mode 100644 index 00000000000..559475ef6d0 --- /dev/null +++ b/apps/webapp/src/script/page/MainContent/panels/preferences/avPreferences/callOptions.test.tsx @@ -0,0 +1,227 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {act, fireEvent, render} from '@testing-library/react'; +import {amplify} from 'amplify'; + +import {Runtime} from '@wireapp/commons'; +import {WebAppEvents} from '@wireapp/webapp-events'; + +import {PrimaryModal} from 'Components/Modals/PrimaryModal'; +import type {MediaConstraintsHandler} from 'Repositories/media/MediaConstraintsHandler'; +import type {PropertiesRepository} from 'Repositories/properties/PropertiesRepository'; +import {withTheme} from 'src/script/auth/util/test/TestUtil'; + +import {CallOptions} from './CallOptions'; + +import {Config} from '../../../../../Config'; + +jest.mock('Util/localizerUtil', () => ({ + t: (key: string) => key, +})); + +type DesktopSettingsMock = { + isHardwareAccelerationEnabled: jest.Mock; + setHardwareAccelerationEnabled: jest.Mock; +}; + +type PrimaryModalOptions = Parameters[1]; + +const mockDesktopSettings = (hwEnabled: boolean): DesktopSettingsMock => ({ + isHardwareAccelerationEnabled: jest.fn().mockReturnValue(hwEnabled), + setHardwareAccelerationEnabled: jest.fn(), +}); + +const getDefaultProps = () => { + const constraintsHandler = { + getAgcPreference: jest.fn().mockReturnValue(false), + setAgcPreference: jest.fn(), + } as unknown as MediaConstraintsHandler; + + const propertiesRepository = { + properties: { + settings: { + call: { + enable_vbr_encoding: false, + enable_soundless_incoming_calls: false, + enable_press_space_to_unmute: false, + }, + }, + } as PropertiesRepository['properties'], + savePreference: jest.fn(), + } as unknown as PropertiesRepository; + + return { + constraintsHandler, + propertiesRepository, + }; +}; + +const setupDesktop = (hwEnabled: boolean): DesktopSettingsMock => { + const desktopSettings = mockDesktopSettings(hwEnabled); + jest.spyOn(Runtime, 'isDesktopApp').mockReturnValue(true); + jest + .spyOn(Config, 'getDesktopSettings') + .mockReturnValue(desktopSettings as ReturnType); + return desktopSettings; +}; + +beforeEach(() => { + jest.restoreAllMocks(); + + jest.spyOn(Config, 'getConfig').mockReturnValue({ + FEATURE: { + ENFORCE_CONSTANT_BITRATE: false, + ENABLE_PRESS_SPACE_TO_UNMUTE: false, + }, + } as ReturnType); +}); + +describe('CallOptions — hardware acceleration checkbox', () => { + it('is not shown in a browser (non-desktop)', () => { + jest.spyOn(Runtime, 'isDesktopApp').mockReturnValue(false); + jest.spyOn(Config, 'getDesktopSettings').mockReturnValue(undefined); + + const {queryByTestId} = render(withTheme()); + + expect(queryByTestId('status-preference-hardware-acceleration')).toBeNull(); + }); + + it('is not shown when desktopSettings is null even on desktop', () => { + jest.spyOn(Runtime, 'isDesktopApp').mockReturnValue(true); + jest.spyOn(Config, 'getDesktopSettings').mockReturnValue(undefined); + + const {queryByTestId} = render(withTheme()); + + expect(queryByTestId('status-preference-hardware-acceleration')).toBeNull(); + }); + + it('is shown and checked when hardware acceleration is enabled', () => { + setupDesktop(true); + + const {getByTestId} = render(withTheme()); + const checkbox = getByTestId('status-preference-hardware-acceleration') as HTMLInputElement; + + expect(checkbox.checked).toBe(true); + }); + + it('is shown and unchecked when hardware acceleration is disabled', () => { + setupDesktop(false); + + const {getByTestId} = render(withTheme()); + const checkbox = getByTestId('status-preference-hardware-acceleration') as HTMLInputElement; + + expect(checkbox.checked).toBe(false); + }); + + it('opens a confirmation modal when the checkbox is clicked without immediately applying the change', () => { + const desktopSettings = setupDesktop(true); + const showModalSpy = jest.spyOn(PrimaryModal, 'show').mockImplementation(() => {}); + + const {getByTestId} = render(withTheme()); + fireEvent.click(getByTestId('status-preference-hardware-acceleration')); + + expect(showModalSpy).toHaveBeenCalledTimes(1); + expect(showModalSpy).toHaveBeenCalledWith( + PrimaryModal.type.CONFIRM, + expect.objectContaining({ + primaryAction: expect.objectContaining({action: expect.any(Function)}), + }), + ); + expect(desktopSettings.setHardwareAccelerationEnabled).not.toHaveBeenCalled(); + }); + + it('does not publish a restart event when the modal is merely opened', () => { + setupDesktop(true); + jest.spyOn(PrimaryModal, 'show').mockImplementation(() => {}); + const publishSpy = jest.spyOn(amplify, 'publish'); + + const {getByTestId} = render(withTheme()); + fireEvent.click(getByTestId('status-preference-hardware-acceleration')); + + expect(publishSpy).not.toHaveBeenCalledWith(WebAppEvents.LIFECYCLE.RESTART); + }); + + it('disables hardware acceleration and triggers restart when confirmed', () => { + const desktopSettings = setupDesktop(true); + let capturedAction: (() => void) | undefined; + + jest.spyOn(PrimaryModal, 'show').mockImplementation((_type, options: PrimaryModalOptions) => { + const primaryAction = options?.primaryAction; + if (primaryAction && 'action' in primaryAction && typeof primaryAction.action === 'function') { + capturedAction = primaryAction.action as () => void; + } + }); + + const publishSpy = jest.spyOn(amplify, 'publish'); + + const {getByTestId} = render(withTheme()); + fireEvent.click(getByTestId('status-preference-hardware-acceleration')); + + expect(capturedAction).toBeDefined(); + act(() => capturedAction?.()); + + expect(desktopSettings.setHardwareAccelerationEnabled).toHaveBeenCalledWith(false); + expect(publishSpy).toHaveBeenCalledWith(WebAppEvents.LIFECYCLE.RESTART); + + const checkbox = getByTestId('status-preference-hardware-acceleration') as HTMLInputElement; + expect(checkbox.checked).toBe(false); + }); + + it('enables hardware acceleration and triggers restart when confirmed', () => { + const desktopSettings = setupDesktop(false); + let capturedAction: (() => void) | undefined; + + jest.spyOn(PrimaryModal, 'show').mockImplementation((_type, options: PrimaryModalOptions) => { + const primaryAction = options?.primaryAction; + if (primaryAction && 'action' in primaryAction && typeof primaryAction.action === 'function') { + capturedAction = primaryAction.action as () => void; + } + }); + + const publishSpy = jest.spyOn(amplify, 'publish'); + + const {getByTestId} = render(withTheme()); + fireEvent.click(getByTestId('status-preference-hardware-acceleration')); + + expect(capturedAction).toBeDefined(); + act(() => capturedAction?.()); + + expect(desktopSettings.setHardwareAccelerationEnabled).toHaveBeenCalledWith(true); + expect(publishSpy).toHaveBeenCalledWith(WebAppEvents.LIFECYCLE.RESTART); + + const checkbox = getByTestId('status-preference-hardware-acceleration') as HTMLInputElement; + expect(checkbox.checked).toBe(true); + }); + + it('makes no changes when the confirmation modal is dismissed', () => { + const desktopSettings = setupDesktop(true); + jest.spyOn(PrimaryModal, 'show').mockImplementation(() => {}); + const publishSpy = jest.spyOn(amplify, 'publish'); + + const {getByTestId} = render(withTheme()); + fireEvent.click(getByTestId('status-preference-hardware-acceleration')); + + expect(desktopSettings.setHardwareAccelerationEnabled).not.toHaveBeenCalled(); + expect(publishSpy).not.toHaveBeenCalledWith(WebAppEvents.LIFECYCLE.RESTART); + + const checkbox = getByTestId('status-preference-hardware-acceleration') as HTMLInputElement; + expect(checkbox.checked).toBe(true); + }); +}); diff --git a/apps/webapp/src/script/repositories/conversation/linkPreviews/index.ts b/apps/webapp/src/script/repositories/conversation/linkPreviews/index.ts index 6a1491f9bf2..132f4cbe2e3 100644 --- a/apps/webapp/src/script/repositories/conversation/linkPreviews/index.ts +++ b/apps/webapp/src/script/repositories/conversation/linkPreviews/index.ts @@ -52,6 +52,10 @@ declare global { interface Window { openGraphAsync?: (url: string) => Promise; desktopAppConfig?: {version: string; supportsCallingPopoutWindow?: boolean}; + desktopAppSettings?: { + setHardwareAccelerationEnabled: (enabled: boolean) => void; + isHardwareAccelerationEnabled: () => boolean; + }; } } const logger = getLogger('LinkPreviewRepository'); diff --git a/apps/webapp/src/types/i18n.d.ts b/apps/webapp/src/types/i18n.d.ts index 9fa36b5db93..eb59999ddfc 100644 --- a/apps/webapp/src/types/i18n.d.ts +++ b/apps/webapp/src/types/i18n.d.ts @@ -1692,6 +1692,12 @@ declare module 'I18n/en-US.json' { 'preferencesOptionsEmojiReplaceDetail': `:-) → [icon]`; 'preferencesOptionsEnableAgcCheckbox': `Automatic gain control (AGC)`; 'preferencesOptionsEnableAgcDetails': `Enable to allow your microphone volume to be adjusted automatically to ensure all participants in a call are heard with similar and comfortable loudness.`; + 'preferencesOptionsEnableHardwareAcceleration': `Hardware acceleration`; + 'preferencesOptionsEnableHardwareAccelerationDetails': `This improves stability and video performance while reducing CPU usage. If you change the setting, you need to restart the app.`; + 'preferencesOptionsEnableHardwareAccelerationModalCancel': `Cancel`; + 'preferencesOptionsEnableHardwareAccelerationModalMessage': `To update the hardware acceleration setting, you need to restart the app.`; + 'preferencesOptionsEnableHardwareAccelerationModalOk': `Restart App`; + 'preferencesOptionsEnableHardwareAccelerationModalTitle': `Restart app`; 'preferencesOptionsEnablePressSpaceToUnmute': `Unmute with space bar`; 'preferencesOptionsEnablePressSpaceToUnmuteDetails': `Enable to unmute your microphone by pressing and holding the space bar as long as you want to speak. You can use this option in full view.`; 'preferencesOptionsEnableSoundlessIncomingCalls': `Silence other calls`;