From f509bef1fb0f481d46f9797e4b8180d0ea7cc3af Mon Sep 17 00:00:00 2001 From: Hampus Date: Wed, 14 Oct 2026 00:37:40 +0200 Subject: [PATCH] fix(app): keep push-to-talk release delays above 500 ms (#3453) --- .../form/ComboboxNumericInput.test.ts | 36 +++++++++++++++++++ .../components/form/ComboboxNumericInput.ts | 26 ++++++++++++++ .../ui/components/form/FormCombobox.tsx | 17 ++++----- .../modals/tabs/AccessibilityTab.tsx | 3 +- .../components/modals/tabs/UserVoiceTab.tsx | 7 ++-- .../tabs/appearance_tab/MessagesTab.tsx | 3 +- .../modals/tabs/appearance_tab/ScalingTab.tsx | 3 +- 7 files changed, 78 insertions(+), 17 deletions(-) create mode 100644 fluxer_app/src/features/ui/components/form/ComboboxNumericInput.test.ts create mode 100644 fluxer_app/src/features/ui/components/form/ComboboxNumericInput.ts diff --git a/fluxer_app/src/features/ui/components/form/ComboboxNumericInput.test.ts b/fluxer_app/src/features/ui/components/form/ComboboxNumericInput.test.ts new file mode 100644 index 000000000..84b84845a --- /dev/null +++ b/fluxer_app/src/features/ui/components/form/ComboboxNumericInput.test.ts @@ -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); + } + }); +}); diff --git a/fluxer_app/src/features/ui/components/form/ComboboxNumericInput.ts b/fluxer_app/src/features/ui/components/form/ComboboxNumericInput.ts new file mode 100644 index 000000000..ae079b388 --- /dev/null +++ b/fluxer_app/src/features/ui/components/form/ComboboxNumericInput.ts @@ -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>, +): 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; +} diff --git a/fluxer_app/src/features/ui/components/form/FormCombobox.tsx b/fluxer_app/src/features/ui/components/form/FormCombobox.tsx index 891f494b2..64332bd6f 100644 --- a/fluxer_app/src/features/ui/components/form/FormCombobox.tsx +++ b/fluxer_app/src/features/ui/components/form/FormCombobox.tsx @@ -21,17 +21,12 @@ export interface ComboboxOption { isDisabled?: boolean; } -export function resolveNearestNumericOption( +function findOptionByLabel>( inputValue: string, - options: ReadonlyArray>, -): 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 | undefined { + const lowered = inputValue.trim().toLowerCase(); + return options.find((option) => option.label.trim().toLowerCase() === lowered); } export interface ComboboxFilterOption { @@ -197,6 +192,8 @@ export const Combobox = observer(function Combobox< ); const resolveInputValue = useCallback( (inputValue: string): V | undefined => { + const labelMatch = findOptionByLabel(inputValue, options); + if (labelMatch) return labelMatch.value; const customValue = autoSelectValueFromInput?.(inputValue, options); if (customValue !== undefined) return customValue; if (!autoSelectExactMatch) return undefined; diff --git a/fluxer_app/src/features/user/components/modals/tabs/AccessibilityTab.tsx b/fluxer_app/src/features/user/components/modals/tabs/AccessibilityTab.tsx index c1998e826..d7a267cdd 100644 --- a/fluxer_app/src/features/user/components/modals/tabs/AccessibilityTab.tsx +++ b/fluxer_app/src/features/user/components/modals/tabs/AccessibilityTab.tsx @@ -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'; diff --git a/fluxer_app/src/features/user/components/modals/tabs/UserVoiceTab.tsx b/fluxer_app/src/features/user/components/modals/tabs/UserVoiceTab.tsx index 76bdc7582..dc935d9a7 100644 --- a/fluxer_app/src/features/user/components/modals/tabs/UserVoiceTab.tsx +++ b/fluxer_app/src/features/user/components/modals/tabs/UserVoiceTab.tsx @@ -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>, ): 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; diff --git a/fluxer_app/src/features/user/components/modals/tabs/appearance_tab/MessagesTab.tsx b/fluxer_app/src/features/user/components/modals/tabs/appearance_tab/MessagesTab.tsx index d8fd268f5..85d4eb1f2 100644 --- a/fluxer_app/src/features/user/components/modals/tabs/appearance_tab/MessagesTab.tsx +++ b/fluxer_app/src/features/user/components/modals/tabs/appearance_tab/MessagesTab.tsx @@ -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'; diff --git a/fluxer_app/src/features/user/components/modals/tabs/appearance_tab/ScalingTab.tsx b/fluxer_app/src/features/user/components/modals/tabs/appearance_tab/ScalingTab.tsx index 50043f068..93cb58e9d 100644 --- a/fluxer_app/src/features/user/components/modals/tabs/appearance_tab/ScalingTab.tsx +++ b/fluxer_app/src/features/user/components/modals/tabs/appearance_tab/ScalingTab.tsx @@ -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';