Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/webapp/src/i18n/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions apps/webapp/src/script/Config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,13 @@

return window.desktopAppConfig;
},
getDesktopSettings: () => {
if (!Runtime.isDesktopApp()) {
return undefined;
}

return window.desktopAppSettings;

Check warning on line 127 in apps/webapp/src/script/Config.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=wireapp_wire-webapp&issues=AZ1HxaVWPAYx8a-Q6X3C&open=AZ1HxaVWPAYx8a-Q6X3C&pullRequest=20441
},
};

export {Config};
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -54,6 +56,18 @@ const CallOptions = ({constraintsHandler, propertiesRepository}: CallOptionsProp
!!propertiesRepository.properties.settings.call.enable_press_space_to_unmute,
);

const desktopSettings = Config.getDesktopSettings();

const isHardwareAccelerationChangeable = Runtime.isDesktopApp() && !!desktopSettings;

const [isHardwareAccelerationEnabled, setIsHardwareAccelerationEnabled] = useState<boolean>(() => {
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);
Expand Down Expand Up @@ -102,6 +116,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 (
<PreferencesSection title={t('preferencesOptionsCall')}>
<div>
Expand Down Expand Up @@ -153,6 +194,25 @@ const CallOptions = ({constraintsHandler, propertiesRepository}: CallOptionsProp
</p>
</div>
)}

{isHardwareAccelerationChangeable && (
<div className="checkbox-margin">
<Checkbox
onChange={showHardwareAccelerationRestartModal}
checked={isHardwareAccelerationEnabled}
id="status-preference-hardware-acceleration"
data-uie-name="status-preference-hardware-acceleration"
aria-label={t('preferencesOptionsEnableHardwareAcceleration')}
>
<CheckboxLabel htmlFor="status-preference-hardware-acceleration">
{t('preferencesOptionsEnableHardwareAcceleration')}
</CheckboxLabel>
</Checkbox>
<p className="preferences-detail preferences-detail-intended">
{t('preferencesOptionsEnableHardwareAccelerationDetails')}
</p>
</div>
)}
</PreferencesSection>
);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof PrimaryModal.show>[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<typeof Config.getDesktopSettings>);
return desktopSettings;
};

beforeEach(() => {
jest.restoreAllMocks();

jest.spyOn(Config, 'getConfig').mockReturnValue({
FEATURE: {
ENFORCE_CONSTANT_BITRATE: false,
ENABLE_PRESS_SPACE_TO_UNMUTE: false,
},
} as ReturnType<typeof Config.getConfig>);
});

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(<CallOptions {...getDefaultProps()} />));

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(<CallOptions {...getDefaultProps()} />));

expect(queryByTestId('status-preference-hardware-acceleration')).toBeNull();
});

it('is shown and checked when hardware acceleration is enabled', () => {
setupDesktop(true);

const {getByTestId} = render(withTheme(<CallOptions {...getDefaultProps()} />));
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(<CallOptions {...getDefaultProps()} />));
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(<CallOptions {...getDefaultProps()} />));
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(<CallOptions {...getDefaultProps()} />));
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(<CallOptions {...getDefaultProps()} />));
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(<CallOptions {...getDefaultProps()} />));
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(<CallOptions {...getDefaultProps()} />));
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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ declare global {
interface Window {
openGraphAsync?: (url: string) => Promise<OpenGraphResult>;
desktopAppConfig?: {version: string; supportsCallingPopoutWindow?: boolean};
desktopAppSettings?: {
setHardwareAccelerationEnabled: (enabled: boolean) => void;
isHardwareAccelerationEnabled: () => boolean;
};
}
}
const logger = getLogger('LinkPreviewRepository');
Expand Down
6 changes: 6 additions & 0 deletions apps/webapp/src/types/i18n.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
Expand Down
Loading