mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
fix(app): keep push-to-talk release delays above 500 ms (#3453)
This commit is contained in:
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;
|
isDisabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveNearestNumericOption(
|
function findOptionByLabel<V extends Primitive, O extends ComboboxOption<V>>(
|
||||||
inputValue: string,
|
inputValue: string,
|
||||||
options: ReadonlyArray<ComboboxOption<number>>,
|
options: ReadonlyArray<O>,
|
||||||
): number | undefined {
|
): O | undefined {
|
||||||
const numericMatch = inputValue.trim().match(/([0-9]+(?:\.[0-9]+)?)/);
|
const lowered = inputValue.trim().toLowerCase();
|
||||||
if (!numericMatch) return undefined;
|
return options.find((option) => option.label.trim().toLowerCase() === lowered);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ComboboxFilterOption<O> {
|
export interface ComboboxFilterOption<O> {
|
||||||
@@ -197,6 +192,8 @@ export const Combobox = observer(function Combobox<
|
|||||||
);
|
);
|
||||||
const resolveInputValue = useCallback(
|
const resolveInputValue = useCallback(
|
||||||
(inputValue: string): V | undefined => {
|
(inputValue: string): V | undefined => {
|
||||||
|
const labelMatch = findOptionByLabel<V, O>(inputValue, options);
|
||||||
|
if (labelMatch) return labelMatch.value;
|
||||||
const customValue = autoSelectValueFromInput?.(inputValue, options);
|
const customValue = autoSelectValueFromInput?.(inputValue, options);
|
||||||
if (customValue !== undefined) return customValue;
|
if (customValue !== undefined) return customValue;
|
||||||
if (!autoSelectExactMatch) return undefined;
|
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 {Message as MessageModel} from '@app/features/messaging/models/MessagingMessage';
|
||||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||||
import {Button} from '@app/features/ui/button/Button';
|
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 {Switch} from '@app/features/ui/components/form/FormSwitch';
|
||||||
import {MockAvatar} from '@app/features/ui/components/MockAvatar';
|
import {MockAvatar} from '@app/features/ui/components/MockAvatar';
|
||||||
import MobileLayout from '@app/features/ui/state/MobileLayout';
|
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 {Button} from '@app/features/ui/button/Button';
|
||||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||||
import {modal} 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 type {ComboboxOption} from '@app/features/ui/components/form/FormCombobox';
|
||||||
import {Switch} from '@app/features/ui/components/form/FormSwitch';
|
import {Switch} from '@app/features/ui/components/form/FormSwitch';
|
||||||
import {RESET_SLIDER_TO_DEFAULT_VALUE_DESCRIPTOR, Slider} from '@app/features/ui/components/Slider';
|
import {RESET_SLIDER_TO_DEFAULT_VALUE_DESCRIPTOR, Slider} from '@app/features/ui/components/Slider';
|
||||||
@@ -161,10 +162,8 @@ const resolvePushToTalkReleaseDelayInput = (
|
|||||||
options: ReadonlyArray<ComboboxOption<number>>,
|
options: ReadonlyArray<ComboboxOption<number>>,
|
||||||
): number | undefined => {
|
): number | undefined => {
|
||||||
const normalized = inputValue.trim().toLowerCase();
|
const normalized = inputValue.trim().toLowerCase();
|
||||||
const numericMatch = normalized.match(/([0-9]+(?:\.[0-9]+)?)/);
|
const parsedValue = parseNumericInput(normalized);
|
||||||
if (!numericMatch) return undefined;
|
if (parsedValue === undefined) return undefined;
|
||||||
const parsedValue = Number(numericMatch[1]);
|
|
||||||
if (!Number.isFinite(parsedValue)) return undefined;
|
|
||||||
const unitMatch = normalized.match(/[0-9.]\s*([a-z]+)/);
|
const unitMatch = normalized.match(/[0-9.]\s*([a-z]+)/);
|
||||||
const unit = unitMatch?.[1] ?? 'ms';
|
const unit = unitMatch?.[1] ?? 'ms';
|
||||||
const valueInMs = unit.startsWith('s') ? parsedValue * 1000 : parsedValue;
|
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 {getCachedNumberFormat} from '@app/features/i18n/utils/IntlCache';
|
||||||
import {Message as MessageModel} from '@app/features/messaging/models/MessagingMessage';
|
import {Message as MessageModel} from '@app/features/messaging/models/MessagingMessage';
|
||||||
import {isNewMessageGroup} from '@app/features/messaging/utils/MessageGroupingUtils';
|
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 {Switch} from '@app/features/ui/components/form/FormSwitch';
|
||||||
import {canResetSliderValue, SliderResetIconButton} from '@app/features/ui/components/slider/SliderResetIconButton';
|
import {canResetSliderValue, SliderResetIconButton} from '@app/features/ui/components/slider/SliderResetIconButton';
|
||||||
import type {RadioOption} from '@app/features/ui/radio_group/RadioGroup';
|
import type {RadioOption} from '@app/features/ui/radio_group/RadioGroup';
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import Accessibility, {
|
|||||||
} from '@app/features/accessibility/state/Accessibility';
|
} from '@app/features/accessibility/state/Accessibility';
|
||||||
import {APP_ZOOM_LEVEL_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
import {APP_ZOOM_LEVEL_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||||
import {getCachedNumberFormat} from '@app/features/i18n/utils/IntlCache';
|
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 {Slider} from '@app/features/ui/components/Slider';
|
||||||
import {canResetSliderValue, SliderResetIconButton} from '@app/features/ui/components/slider/SliderResetIconButton';
|
import {canResetSliderValue, SliderResetIconButton} from '@app/features/ui/components/slider/SliderResetIconButton';
|
||||||
import {formatRoundedPercentage, roundPercentage} from '@app/features/ui/utils/PercentageFormatting';
|
import {formatRoundedPercentage, roundPercentage} from '@app/features/ui/utils/PercentageFormatting';
|
||||||
|
|||||||
Reference in new issue
Block a user