diff --git a/ACKNOWLEDGMENTS.md b/ACKNOWLEDGMENTS.md index cc85c3245a..f6cf5e7d03 100644 --- a/ACKNOWLEDGMENTS.md +++ b/ACKNOWLEDGMENTS.md @@ -4711,6 +4711,10 @@ Signal Desktop makes use of the following open source projects. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +## parsecurrency + + License: MIT + ## pify MIT License diff --git a/_locales/en/messages.json b/_locales/en/messages.json index a03e15cb2f..c18c6c6238 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -8886,6 +8886,10 @@ "messageformat": "Having issues? Contact support", "description": "In the Donations settings section, this footer text appears during parts of the donation workflow such as when picking a donation currency and amount, or entering the credit card info." }, + "icu:DonateFlow__amount-picker-custom-amount-placeholder": { + "messageformat": "Enter Custom Amount", + "description": "When selecting currency and amount for a donation, this is the placeholder text in the custom amount input box." + }, "icu:DonateFlow__credit-or-debit-card": { "messageformat": "Credit or Debit Card", "description": "When entering payment card details for a donation, this heading indicates the section for credit or debit cards." diff --git a/package.json b/package.json index 1eb4f47508..62e04f1f4f 100644 --- a/package.json +++ b/package.json @@ -182,6 +182,7 @@ "p-map": "2.1.0", "p-queue": "6.6.2", "p-timeout": "4.1.0", + "parsecurrency": "1.1.1", "pify": "3.0.0", "pino": "9.5.0", "protobufjs": "7.3.2", @@ -271,6 +272,7 @@ "@types/node": "20.17.6", "@types/node-fetch": "2.6.12", "@types/normalize-path": "3.0.2", + "@types/parsecurrency": "1.0.2", "@types/pify": "5.0.4", "@types/pixelmatch": "5.2.6", "@types/pngjs": "6.0.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 38822e8498..587da19f6c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -302,6 +302,9 @@ importers: p-timeout: specifier: 4.1.0 version: 4.1.0 + parsecurrency: + specifier: 1.1.1 + version: 1.1.1 pify: specifier: 3.0.0 version: 3.0.0 @@ -564,6 +567,9 @@ importers: '@types/normalize-path': specifier: 3.0.2 version: 3.0.2 + '@types/parsecurrency': + specifier: 1.0.2 + version: 1.0.2 '@types/pify': specifier: 5.0.4 version: 5.0.4 @@ -3957,6 +3963,9 @@ packages: '@types/parse-json@4.0.2': resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} + '@types/parsecurrency@1.0.2': + resolution: {integrity: sha512-1YxKUYcrfIdCtuahtFl4RxuqZhkTaicqqWOUkgsid7zRbyZInKkFWT88kt5zKxm6ZaP+hb1NT51zTt5jmFqToQ==} + '@types/pify@5.0.4': resolution: {integrity: sha512-gxKJ1Aw8LbyCsCQWIsip9bYKJCNsKHMoZoQMAe2IWH7U7hgp/l6TvJpbFvu8ZlGBimjZZNvEx2S1ZQlj02ayNQ==} @@ -8437,6 +8446,9 @@ packages: resolution: {integrity: sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q==} engines: {node: '>=0.10.0'} + parsecurrency@1.1.1: + resolution: {integrity: sha512-IAw/8PSFgiko70KfZGv63rbEXhmVu+zpb42PvEtgHAm83Mze3eQJHWV1ZoOhPnrYeOyufvv0GS6hZDuQOdBH4Q==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -14779,6 +14791,8 @@ snapshots: '@types/parse-json@4.0.2': {} + '@types/parsecurrency@1.0.2': {} + '@types/pify@5.0.4': {} '@types/pixelmatch@5.2.6': @@ -20183,6 +20197,8 @@ snapshots: parse-passwd@1.0.0: {} + parsecurrency@1.1.1: {} + parseurl@1.3.3: {} pascal-case@3.1.2: diff --git a/stylesheets/components/DonationForm.scss b/stylesheets/components/DonationForm.scss index 3c96916ab6..2db60566e4 100644 --- a/stylesheets/components/DonationForm.scss +++ b/stylesheets/components/DonationForm.scss @@ -56,10 +56,9 @@ a.DonationFormHelpFooter__ContactSupportLink { } .DonationAmountPicker__PresetButton, -.DonationForm.PreferencesDonations - .DonationAmountPicker__CustomInput__container, -.DonationForm .DonationAmountPicker__CustomInput--selected__container, -.DonationForm .DonationAmountPicker__CustomInput--with-error__container { +.DonationForm.PreferencesDonations .DonationAmountPicker__CustomInput, +.DonationForm .DonationAmountPicker__CustomInput--selected, +.DonationForm .DonationAmountPicker__CustomInput--with-error { margin-block: 5px; margin-inline: 5px; border-width: 0.5px; @@ -70,12 +69,10 @@ a.DonationFormHelpFooter__ContactSupportLink { .DonationAmountPicker__PresetButton, .DonationForm .DonationForm__CurrencySelect.module-select select, +.DonationForm.PreferencesDonations .DonationAmountPicker__CustomInput, +.DonationForm.PreferencesDonations .DonationAmountPicker__CustomInput--selected, .DonationForm.PreferencesDonations - .DonationAmountPicker__CustomInput__container, -.DonationForm.PreferencesDonations - .DonationAmountPicker__CustomInput--selected__container, -.DonationForm.PreferencesDonations - .DonationAmountPicker__CustomInput--with-error__container { + .DonationAmountPicker__CustomInput--with-error { background-color: light-dark( variables.$color-white, variables.$color-gray-85 @@ -97,40 +94,38 @@ a.DonationFormHelpFooter__ContactSupportLink { } .DonationAmountPicker__PresetButton--selected, -.DonationForm .DonationAmountPicker__CustomInput--selected__container, -.DonationForm - .DonationAmountPicker__CustomInput--with-error__container:focus-within, -.DonationForm .DonationAmountPicker__CustomInput__container:focus-within { +.DonationForm .DonationAmountPicker__CustomInput--selected, +.DonationForm .DonationAmountPicker__CustomInput--with-error:focus, +.DonationForm .DonationAmountPicker__CustomInput:focus { border-color: variables.$color-ultramarine; outline: 2.5px solid variables.$color-ultramarine; outline-offset: -2.5px; } -.DonationForm .DonationAmountPicker__CustomInput__container, -.DonationForm .DonationAmountPicker__CustomInput--selected__container, -.DonationForm .DonationAmountPicker__CustomInput--with-error__container { +.DonationForm .DonationAmountPicker__CustomInput, +.DonationForm .DonationAmountPicker__CustomInput--selected, +.DonationForm .DonationAmountPicker__CustomInput--with-error { width: 320px; padding-block: 0; border-width: 0.5px; } -.DonationForm - .DonationAmountPicker__CustomInput--with-error__container:not(:focus-within) { +.DonationForm .DonationAmountPicker__CustomInput--with-error:not(:focus) { border-color: variables.$color-deep-red; outline: 2.5px solid variables.$color-deep-red; outline-offset: -2.5px; } -.DonationForm .DonationAmountPicker__CustomInput__input, -.DonationForm .DonationAmountPicker__CustomInput--selected__input, -.DonationForm .DonationAmountPicker__CustomInput--with-error__input { +.DonationForm .DonationAmountPicker__CustomInput, +.DonationForm .DonationAmountPicker__CustomInput--selected, +.DonationForm .DonationAmountPicker__CustomInput--with-error { @include mixins.font-body-1; padding-inline: 12px; padding-block: 14px; text-align: center; } -.DonationAmountPicker__CustomInput__input:not(:focus)::placeholder { +.DonationAmountPicker__CustomInput:not(:focus)::placeholder { color: light-dark( variables.$color-black-alpha-85, variables.$color-white-alpha-85 @@ -138,9 +133,9 @@ a.DonationFormHelpFooter__ContactSupportLink { opacity: 1; } -.DonationAmountPicker__CustomInput__input:focus::placeholder, -.DonationAmountPicker__CustomInput--selected__input:focus::placeholder, -.DonationAmountPicker__CustomInput--with-error__input:focus::placeholder { +.DonationAmountPicker__CustomInput:focus::placeholder, +.DonationAmountPicker__CustomInput--selected:focus::placeholder, +.DonationAmountPicker__CustomInput--with-error:focus::placeholder { color: transparent; } diff --git a/ts/components/PreferencesDonateFlow.tsx b/ts/components/PreferencesDonateFlow.tsx index 8e1d52fad3..ac8b234798 100644 --- a/ts/components/PreferencesDonateFlow.tsx +++ b/ts/components/PreferencesDonateFlow.tsx @@ -44,7 +44,6 @@ import { toHumanCurrencyString, toStripeDonationAmount, } from '../util/currency'; -import { Input } from './Input'; import { PreferencesContent } from './Preferences'; import type { SubmitDonationType } from '../state/ducks/donations'; import { Select } from './Select'; @@ -62,6 +61,7 @@ import { } from './preferences/donations/DonateInputCardCvc'; import { I18n } from './I18n'; import { strictAssert } from '../util/assert'; +import { DonateInputAmount } from './preferences/donations/DonateInputAmount'; const SUPPORT_URL = 'https://support.signal.org/hc/requests/new?desktop'; @@ -118,6 +118,12 @@ export function PreferencesDonateFlow({ CardFormValues | undefined >(); + // When changing currency, clear out the last selected amount + const handleAmountPickerCurrencyChanged = useCallback((value: string) => { + setAmount(undefined); + setCurrency(value); + }, []); + const handleAmountPickerResult = useCallback((result: AmountPickerResult) => { const { currency: pickedCurrency, amount: pickedAmount } = result; setAmount(pickedAmount); @@ -180,6 +186,7 @@ export function PreferencesDonateFlow({ initialCurrency={currency} donationAmountsConfig={donationAmountsConfig} validCurrencies={validCurrencies} + onChangeCurrency={handleAmountPickerCurrencyChanged} onSubmit={handleAmountPickerResult} /> @@ -247,6 +254,7 @@ type AmountPickerProps = { initialCurrency: string | undefined; donationAmountsConfig: OneTimeDonationHumanAmounts | undefined; validCurrencies: ReadonlyArray; + onChangeCurrency: (value: string) => void; onSubmit: (result: AmountPickerResult) => void; }; @@ -256,6 +264,7 @@ function AmountPicker({ initialAmount, initialCurrency = 'usd', validCurrencies, + onChangeCurrency, onSubmit, }: AmountPickerProps): JSX.Element { const [currency, setCurrency] = useState(initialCurrency); @@ -263,7 +272,9 @@ function AmountPicker({ const [presetAmount, setPresetAmount] = useState< HumanDonationAmount | undefined >(); - const [customAmount, setCustomAmount] = useState(); + const [customAmount, setCustomAmount] = useState( + initialAmount?.toString() ?? '' + ); // Reset amount selections when API donation config or selected currency changes // Memo here so preset options instantly load when component mounts. @@ -283,10 +294,10 @@ function AmountPicker({ presetAmountOptions.find(option => option === initialAmount) ) { setPresetAmount(initialAmount); - setCustomAmount(undefined); + setCustomAmount(''); } else { setPresetAmount(undefined); - setCustomAmount(initialAmount?.toString()); + setCustomAmount(initialAmount?.toString() ?? ''); } }, [initialAmount, presetAmountOptions]); @@ -300,7 +311,7 @@ function AmountPicker({ }, [donationAmountsConfig, currency]); const currencyOptionsForSelect = useMemo(() => { - return validCurrencies.map((currencyString: string) => { + return validCurrencies.toSorted().map((currencyString: string) => { return { text: currencyString.toUpperCase(), value: currencyString }; }); }, [validCurrencies]); @@ -342,9 +353,14 @@ function AmountPicker({ }; }, [currency, customAmount, minimumAmount]); - const handleCurrencyChanged = useCallback((value: string) => { - setCurrency(value); - }, []); + const handleCurrencyChanged = useCallback( + (value: string) => { + setCurrency(value); + setCustomAmount(''); + onChangeCurrency(value); + }, + [onChangeCurrency] + ); const handleCustomAmountChanged = useCallback((value: string) => { // Custom amount overrides any selected preset amount @@ -394,7 +410,7 @@ function AmountPicker({ })} key={value} onClick={() => { - setCustomAmount(undefined); + setCustomAmount(''); setPresetAmount(value); }} type="button" @@ -402,13 +418,15 @@ function AmountPicker({ {toHumanCurrencyString({ amount: value, currency })} ))} - setPresetAmount(undefined)} - placeholder="Enter Custom Amount" + placeholder={i18n( + 'icu:DonateFlow__amount-picker-custom-amount-placeholder' + )} value={customAmount} /> diff --git a/ts/components/preferences/donations/DonateInputAmount.tsx b/ts/components/preferences/donations/DonateInputAmount.tsx new file mode 100644 index 0000000000..f92b9b6fd9 --- /dev/null +++ b/ts/components/preferences/donations/DonateInputAmount.tsx @@ -0,0 +1,142 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +import type { FormEvent } from 'react'; +import React, { memo, useCallback, useMemo, useRef } from 'react'; +import type { Formatter, FormatterToken } from '@signalapp/minimask'; +import { useInputMask } from '../../../hooks/useInputMask'; +import type { CurrencyFormatResult } from '../../../util/currency'; +import { getCurrencyFormat } from '../../../util/currency'; + +export type DonateInputAmountProps = Readonly<{ + className: string; + currency: string; + id: string; + placeholder?: string; + value: string; + onValueChange: (newValue: string) => void; + onBlur?: () => void; + onFocus?: () => void; +}>; + +const getAmountFormatter = ( + currencyFormat: CurrencyFormatResult | undefined +): Formatter => { + return (input: string) => { + const { symbolPrefix, symbolSuffix, decimal, group } = currencyFormat ?? {}; + const tokens: Array = []; + let isDecimalPresent = false; + let isDigitPresent = false; + let decimalLength = 0; + + if (symbolPrefix) { + for (const char of symbolPrefix.split('')) { + tokens.push({ char, index: 0, mask: true }); + } + } + + for (const [index, char] of input.split('').entries()) { + if (/[\d., ']/.test(char) || (group && char === group)) { + if (decimal && char === decimal) { + // Prevent multiple decimal separators + if (isDecimalPresent) { + continue; + } else { + isDecimalPresent = true; + // Force leading 0 for decimal-only values (for parseCurrencyString) + if (!isDigitPresent) { + tokens.push({ char: '0', index, mask: false }); + } + } + } + + if (!isDigitPresent && /\d/.test(char)) { + isDigitPresent = true; + } + + // Prevent over 2 decimal digits due to issues with parsing + if (isDecimalPresent) { + if (decimalLength > 2) { + continue; + } + + decimalLength += 1; + } + + tokens.push({ char, index, mask: false }); + } + } + + if (symbolSuffix) { + const lastIndex = tokens[tokens.length - 1]?.index ?? 0; + for (const char of symbolSuffix.split('')) { + tokens.push({ char, index: lastIndex, mask: true }); + } + } + + return tokens; + }; +}; + +export const DonateInputAmount = memo(function DonateInputAmount( + props: DonateInputAmountProps +) { + const { currency, onBlur, onFocus, onValueChange, value } = props; + + const inputRef = useRef(null); + + const currencyFormat = useMemo( + () => getCurrencyFormat(currency), + [currency] + ); + + const amountFormatter = useMemo( + () => getAmountFormatter(currencyFormat), + [currencyFormat] + ); + useInputMask(inputRef, amountFormatter); + + const handleInput = useCallback( + (event: FormEvent) => { + onValueChange(event.currentTarget.value); + }, + [onValueChange] + ); + + const onFocusWithCurrencyHandler = useCallback(() => { + // Initialize field with the currency symbol + if (!value && currencyFormat?.symbol) { + onValueChange(currencyFormat?.symbol); + } + + if (typeof onFocus === 'function') { + onFocus(); + } + }, [currencyFormat, onFocus, onValueChange, value]); + + const onBlurWithCurrencyHandler = useCallback(() => { + // If nothing was typed then remove currency symbol to restore placeholder + if (value === currencyFormat?.symbol) { + onValueChange(''); + } + + if (typeof onBlur === 'function') { + onBlur(); + } + }, [currencyFormat, onBlur, onValueChange, value]); + + return ( + + ); +}); diff --git a/ts/test-node/util/currency_test.ts b/ts/test-node/util/currency_test.ts index b118739ce3..217f4d37bc 100644 --- a/ts/test-node/util/currency_test.ts +++ b/ts/test-node/util/currency_test.ts @@ -28,18 +28,33 @@ describe('parseCurrencyString', () => { testFn({ currency: 'usd', value: '10' }, 10); testFn({ currency: 'usd', value: '10.0' }, 10); testFn({ currency: 'usd', value: '10.00' }, 10); - testFn({ currency: 'usd', value: '10.000' }, 10); testFn({ currency: 'usd', value: '10.50' }, 10.5); - testFn({ currency: 'usd', value: '10.6969' }, 10.69); - testFn({ currency: 'usd', value: '.69' }, 0.69); testFn({ currency: 'usd', value: '0.69' }, 0.69); + testFn({ currency: 'usd', value: '$10' }, 10); + testFn({ currency: 'usd', value: '$10.00' }, 10); + testFn({ currency: 'usd', value: '$0.69' }, 0.69); + testFn({ currency: 'usd', value: '$14,000.50' }, 14000.5); + testFn({ currency: 'usd', value: '$1,000,000' }, 1000000); }); it('handles JPY', () => { testFn({ currency: 'jpy', value: '1000' }, 1000); testFn({ currency: 'jpy', value: '1000.0' }, 1000); testFn({ currency: 'jpy', value: '1000.5' }, 1000); - testFn({ currency: 'jpy', value: '1000.5555' }, 1000); + testFn({ currency: 'jpy', value: '1000.55' }, 1000); + testFn({ currency: 'jpy', value: '¥1000' }, 1000); + testFn({ currency: 'jpy', value: '¥1,000' }, 1000); + }); + + it('handles EUR', () => { + testFn({ currency: 'eur', value: '€10' }, 10); + testFn({ currency: 'eur', value: '10€' }, 10); + testFn({ currency: 'eur', value: '€14.000,50' }, 14000.5); + testFn({ currency: 'eur', value: '€14,000.5' }, 14000.5); + }); + + it('handles SEK', () => { + testFn({ currency: 'sek', value: '14 000,99 kr' }, 14000.99); }); it('handles malformed input', () => { @@ -47,6 +62,7 @@ describe('parseCurrencyString', () => { testFn({ currency: 'usd', value: '??' }, undefined); testFn({ currency: 'usd', value: '-50' }, undefined); testFn({ currency: 'usd', value: 'abc' }, undefined); + testFn({ currency: 'usd', value: '$' }, undefined); }); }); diff --git a/ts/util/currency.ts b/ts/util/currency.ts index 1b64aa3b4e..97b714212d 100644 --- a/ts/util/currency.ts +++ b/ts/util/currency.ts @@ -1,6 +1,7 @@ // Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only +import parseCurrency from 'parsecurrency'; import type { HumanDonationAmount, DonationReceipt, @@ -38,10 +39,18 @@ export function parseCurrencyString({ currency: string; value: string; }): HumanDonationAmount | undefined { - const valueAsFloat = parseFloat(value); + // Known issues with parseCurrency: + // Triple decimal interpreted as a thousands group separator e.g. 1.000 -> 1000 + // Decimals must have leading 0 or else are parsed as integers e.g. .42 -> 42 + const { value: parsedCurrencyValue } = parseCurrency(value) ?? {}; + if (!parsedCurrencyValue) { + return; + } + const truncatedAmount = ZERO_DECIMAL_CURRENCIES.has(currency.toLowerCase()) - ? Math.trunc(valueAsFloat) - : Math.trunc(valueAsFloat * 100) / 100; + ? Math.trunc(parsedCurrencyValue) + : Math.trunc(parsedCurrencyValue * 100) / 100; + const parsed = safeParseStrict(humanDonationAmountSchema, truncatedAmount); if (!parsed.success) { return; @@ -50,6 +59,13 @@ export function parseCurrencyString({ return parsed.data; } +function getLocales(): Intl.LocalesArgument { + const preferredSystemLocales = + window.SignalContext.getPreferredSystemLocales(); + const localeOverride = window.SignalContext.getLocaleOverride(); + return localeOverride != null ? [localeOverride] : preferredSystemLocales; +} + // Takes a donation amount and currency and returns a human readable currency string // formatted in the locale's format using Intl.NumberFormat. e.g. $10; ¥1000; 10 € // In case of error, returns empty string. @@ -67,17 +83,11 @@ export function toHumanCurrencyString({ } try { - const preferredSystemLocales = - window.SignalContext.getPreferredSystemLocales(); - const localeOverride = window.SignalContext.getLocaleOverride(); - const locales = - localeOverride != null ? [localeOverride] : preferredSystemLocales; - const fractionOptions = showInsignificantFractionDigits || amount % 1 !== 0 ? {} : { minimumFractionDigits: 0 }; - const formatter = new Intl.NumberFormat(locales, { + const formatter = new Intl.NumberFormat(getLocales(), { style: 'currency', currency, ...fractionOptions, @@ -88,6 +98,57 @@ export function toHumanCurrencyString({ } } +export type CurrencyFormatResult = { + decimal: string | undefined; + group: string | undefined; + symbol: string; + symbolPrefix: string; + symbolSuffix: string; +}; + +export function getCurrencyFormat( + currency: string +): CurrencyFormatResult | undefined { + if (currency == null) { + return; + } + + try { + const formatter = new Intl.NumberFormat(getLocales(), { + style: 'currency', + currency, + currencyDisplay: 'narrowSymbol', + }); + + let symbol = ''; + let symbolPrefix = ''; + let symbolSuffix = ''; + let group; + let decimal; + + const parts = formatter.formatToParts(123456); + for (const [index, part] of parts.entries()) { + const { type, value } = part; + if (type === 'currency') { + symbol += value; + if (index === 0) { + symbolPrefix = part.value; + } else { + symbolSuffix = part.value; + } + } else if (type === 'group') { + group = value; + } else if (type === 'decimal') { + decimal = value; + } + } + + return { decimal, group, symbol, symbolPrefix, symbolSuffix }; + } catch { + return undefined; + } +} + /** * Takes a number and brands as HumanDonationAmount type, which indicates actual * units (e.g. 10 for 10 USD; 1000 for 1000 JPY). diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 0001e47e43..777eea4714 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -1486,6 +1486,13 @@ "updated": "2025-06-26T23:23:57.292Z", "reasonDetail": "Holding on to a close function" }, + { + "rule": "React-useRef", + "path": "ts/components/preferences/donations/DonateInputAmount.tsx", + "line": " const inputRef = useRef(null);", + "reasonCategory": "usageTrusted", + "updated": "2025-08-01T20:31:42.993Z" + }, { "rule": "React-useRef", "path": "ts/components/preferences/donations/DonateInputCardCvc.tsx", diff --git a/tsconfig.json b/tsconfig.json index 17daecdaf1..f370f8eeda 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -33,6 +33,7 @@ "DOM", // Required to access `window` "DOM.Iterable", "ES2022", + "ES2023.Array", "ESNext.Disposable" // For `playwright` ], /* Specify what JSX code is generated. */