fix(app): keep push-to-talk release delays above 500 ms (#3453)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-14 00:37:40 +02:00
1 parent 959b03b5f4
commit f509bef1fb
7 files changed
+78 -17

No files matched your search

@@ -0,0 +1,36 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {parseNumericInput, resolveNearestNumericOption} from '@app/features/ui/components/form/ComboboxNumericInput';
import {describe, expect, it} from 'vitest';
const RELEASE_DELAY_OPTIONS = [20, 100, 250, 500, 1000, 1500, 2000].map((value) => ({
value,
label: new Intl.NumberFormat('en-US', {style: 'unit', unit: 'millisecond', unitDisplay: 'narrow'}).format(value),
}));
describe('parseNumericInput', () => {
it('reads grouped thousands in common locale formats', () => {
expect(parseNumericInput('1,000ms')).toBe(1000);
expect(parseNumericInput('1.500 ms')).toBe(1500);
expect(parseNumericInput('2 000 ms')).toBe(2000);
expect(parseNumericInput("1'000")).toBe(1000);
});
it('keeps decimals', () => {
expect(parseNumericInput('1.5 s')).toBe(1.5);
expect(parseNumericInput('1,5 s')).toBe(1.5);
expect(parseNumericInput('250')).toBe(250);
});
it('returns undefined without digits', () => {
expect(parseNumericInput('ms')).toBeUndefined();
});
});
describe('resolveNearestNumericOption', () => {
it('keeps release delays above 500 ms when the selected label is read back', () => {
for (const option of RELEASE_DELAY_OPTIONS) {
expect(resolveNearestNumericOption(option.label, RELEASE_DELAY_OPTIONS)).toBe(option.value);
}
});
});
@@ -0,0 +1,26 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ComboboxOption} from '@app/features/ui/components/form/FormCombobox';
const DIGIT_GROUP_SEPARATOR = /(?<=\d)[,.'\u2019\s\u00a0\u202f](?=\d{3}(?!\d))/g;
export function parseNumericInput(inputValue: string): number | undefined {
const numericMatch = inputValue
.trim()
.replace(DIGIT_GROUP_SEPARATOR, '')
.match(/([0-9]+(?:[.,][0-9]+)?)/);
if (!numericMatch) return undefined;
const parsedValue = Number(numericMatch[1].replace(',', '.'));
return Number.isFinite(parsedValue) ? parsedValue : undefined;
}
export function resolveNearestNumericOption(
inputValue: string,
options: ReadonlyArray<ComboboxOption<number>>,
): number | undefined {
const parsedValue = parseNumericInput(inputValue);
if (parsedValue === undefined) return undefined;
return options.reduce((nearest, option) =>
Math.abs(option.value - parsedValue) < Math.abs(nearest.value - parsedValue) ? option : nearest,
).value;
}
@@ -21,17 +21,12 @@ export interface ComboboxOption<V extends Primitive = string> {
isDisabled?: boolean;
}
export function resolveNearestNumericOption(
function findOptionByLabel<V extends Primitive, O extends ComboboxOption<V>>(
inputValue: string,
options: ReadonlyArray<ComboboxOption<number>>,
): number | undefined {
const numericMatch = inputValue.trim().match(/([0-9]+(?:\.[0-9]+)?)/);
if (!numericMatch) return undefined;
const parsedValue = Number(numericMatch[1]);
if (!Number.isFinite(parsedValue)) return undefined;
return options.reduce((nearest, option) =>
Math.abs(option.value - parsedValue) < Math.abs(nearest.value - parsedValue) ? option : nearest,
).value;
options: ReadonlyArray<O>,
): O | undefined {
const lowered = inputValue.trim().toLowerCase();
return options.find((option) => option.label.trim().toLowerCase() === lowered);
}
export interface ComboboxFilterOption<O> {
@@ -197,6 +192,8 @@ export const Combobox = observer(function Combobox<
);
const resolveInputValue = useCallback(
(inputValue: string): V | undefined => {
const labelMatch = findOptionByLabel<V, O>(inputValue, options);
if (labelMatch) return labelMatch.value;
const customValue = autoSelectValueFromInput?.(inputValue, options);
if (customValue !== undefined) return customValue;
if (!autoSelectExactMatch) return undefined;
@@ -13,7 +13,8 @@ import {getCachedNumberFormat} from '@app/features/i18n/utils/IntlCache';
import {Message as MessageModel} from '@app/features/messaging/models/MessagingMessage';
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
import {Button} from '@app/features/ui/button/Button';
import {type ComboboxOption, resolveNearestNumericOption} from '@app/features/ui/components/form/FormCombobox';
import {resolveNearestNumericOption} from '@app/features/ui/components/form/ComboboxNumericInput';
import type {ComboboxOption} from '@app/features/ui/components/form/FormCombobox';
import {Switch} from '@app/features/ui/components/form/FormSwitch';
import {MockAvatar} from '@app/features/ui/components/MockAvatar';
import MobileLayout from '@app/features/ui/state/MobileLayout';
@@ -15,6 +15,7 @@ import NativePermission from '@app/features/permissions/system/state/NativePermi
import {Button} from '@app/features/ui/button/Button';
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
import {modal} from '@app/features/ui/commands/ModalCommands';
import {parseNumericInput} from '@app/features/ui/components/form/ComboboxNumericInput';
import type {ComboboxOption} from '@app/features/ui/components/form/FormCombobox';
import {Switch} from '@app/features/ui/components/form/FormSwitch';
import {RESET_SLIDER_TO_DEFAULT_VALUE_DESCRIPTOR, Slider} from '@app/features/ui/components/Slider';
@@ -161,10 +162,8 @@ const resolvePushToTalkReleaseDelayInput = (
options: ReadonlyArray<ComboboxOption<number>>,
): number | undefined => {
const normalized = inputValue.trim().toLowerCase();
const numericMatch = normalized.match(/([0-9]+(?:\.[0-9]+)?)/);
if (!numericMatch) return undefined;
const parsedValue = Number(numericMatch[1]);
if (!Number.isFinite(parsedValue)) return undefined;
const parsedValue = parseNumericInput(normalized);
if (parsedValue === undefined) return undefined;
const unitMatch = normalized.match(/[0-9.]\s*([a-z]+)/);
const unit = unitMatch?.[1] ?? 'ms';
const valueInMs = unit.startsWith('s') ? parsedValue * 1000 : parsedValue;
@@ -12,7 +12,8 @@ import Channels from '@app/features/channel/state/Channels';
import {getCachedNumberFormat} from '@app/features/i18n/utils/IntlCache';
import {Message as MessageModel} from '@app/features/messaging/models/MessagingMessage';
import {isNewMessageGroup} from '@app/features/messaging/utils/MessageGroupingUtils';
import {type ComboboxOption, resolveNearestNumericOption} from '@app/features/ui/components/form/FormCombobox';
import {resolveNearestNumericOption} from '@app/features/ui/components/form/ComboboxNumericInput';
import type {ComboboxOption} from '@app/features/ui/components/form/FormCombobox';
import {Switch} from '@app/features/ui/components/form/FormSwitch';
import {canResetSliderValue, SliderResetIconButton} from '@app/features/ui/components/slider/SliderResetIconButton';
import type {RadioOption} from '@app/features/ui/radio_group/RadioGroup';
@@ -8,7 +8,8 @@ import Accessibility, {
} from '@app/features/accessibility/state/Accessibility';
import {APP_ZOOM_LEVEL_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import {getCachedNumberFormat} from '@app/features/i18n/utils/IntlCache';
import {type ComboboxOption, resolveNearestNumericOption} from '@app/features/ui/components/form/FormCombobox';
import {resolveNearestNumericOption} from '@app/features/ui/components/form/ComboboxNumericInput';
import type {ComboboxOption} from '@app/features/ui/components/form/FormCombobox';
import {Slider} from '@app/features/ui/components/Slider';
import {canResetSliderValue, SliderResetIconButton} from '@app/features/ui/components/slider/SliderResetIconButton';
import {formatRoundedPercentage, roundPercentage} from '@app/features/ui/utils/PercentageFormatting';