Skip to content

utils: use app locale for number separators#3927

Open
cherry-1729-9090 wants to merge 1 commit intoZeusLN:masterfrom
cherry-1729-9090:feature/app-language-number-separators
Open

utils: use app locale for number separators#3927
cherry-1729-9090 wants to merge 1 commit intoZeusLN:masterfrom
cherry-1729-9090:feature/app-language-number-separators

Conversation

@cherry-1729-9090
Copy link
Copy Markdown
Contributor

Description

Relates to issue: #3833

This PR makes number separators follow the app language across the app.
Screenshot 2026-03-31 at 2 03 53 AM
Screenshot 2026-03-31 at 2 04 58 AM

Before this change, Bitcoin and sats were always shown in English-style formatting, while fiat formatting depended on the selected currency. That could show mixed separator styles on the same screen.

With this change, the selected app language is used as the single source of truth for number separators for:

  • sats
  • BTC
  • fiat
  • exchange rates
  • keypad decimal display
  • related amount input and conversion flows

This keeps formatting consistent when switching between languages like English and German.

This pull request is categorized as a:

  • New feature
  • Bug fix
  • Code refactor
  • Configuration change
  • Locales update
  • Quality assurance
  • Other

Checklist

  • I’ve run yarn run tsc and made sure my code compiles correctly
  • I’ve run yarn run lint and made sure my code didn’t contain any problematic patterns
  • I’ve run yarn run prettier and made sure my code is formatted correctly
  • I’ve run yarn run test and made sure all of the tests pass

Testing

If you modified or added a utility file, did you add new unit tests?

  • No, I’m a fool
  • Yes
  • N/A

I have tested this PR on the following platforms (please specify OS version and phone model/VM):

  • Android
  • iOS

I have tested this PR with the following types of nodes (please specify node version and API version where appropriate):

On-device

  • Embedded LND
  • LDK Node

Remote

  • LND (REST)
  • LND (Lightning Node Connect)
  • Core Lightning (CLNRest)
  • Nostr Wallet Connect
  • LndHub

Locales

  • I’ve added new locale text that requires translations
  • I’m aware that new translations should be made on the ZEUS Transfix page and not directly to this repo

Third Party Dependencies and Packages

  • Contributors will need to run yarn after this PR is merged in
  • 3rd party dependencies have been modified:
    • verify that package.json and yarn.lock have been properly updated
    • verify that dependencies are installed for both iOS and Android platforms

Other:

  • Changes were made that require an update to the README
  • Changes were made that require an update to onboarding

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a locale-aware number formatting and parsing system, replacing legacy separatorSwap logic and manual string manipulations with centralized utilities in UnitsUtils.ts. These changes ensure consistent use of decimal and group separators across the application, including in the Amount, AmountInput, and CurrencyConverter components. The Amount component was also updated to fetch fiat rates when relevant settings change. Feedback suggests adding explanatory comments to the splitNumericValue function to improve the maintainability of its complex parsing heuristics.

Comment thread utils/UnitsUtils.ts
Comment on lines +86 to +233
const splitNumericValue = (value: string | number, locale?: string) => {
const { decimalSeparator, groupSeparator } =
getNumberFormatSettings(locale);
const input = `${value ?? 0}`.trim().replace(/\s/g, '');

if (!input) {
return {
sign: '',
integerPart: '0',
decimalPart: undefined,
hasExplicitDecimal: false
};
}

const sign = input.startsWith('-') ? '-' : '';
const unsignedInput = sign ? input.slice(1) : input;
const fallbackSeparator = decimalSeparator === '.' ? ',' : '.';

const normalizeGroupedValue = (integerValue: string) =>
integerValue.replace(/[.,]/g, '') || '0';

const splitOnSeparator = (separator: string) => {
const parts = unsignedInput.split(separator);

if (parts.length === 1) {
return undefined;
}

if (parts.length > 2) {
const decimalPart = parts.pop() || '';
return {
sign,
integerPart: normalizeGroupedValue(parts.join(separator)),
decimalPart: decimalPart.replace(/[.,]/g, ''),
hasExplicitDecimal: true
};
}

const [integerPart, decimalPart = ''] = parts;
return {
sign,
integerPart: normalizeGroupedValue(integerPart),
decimalPart: decimalPart.replace(/[.,]/g, ''),
hasExplicitDecimal: true
};
};

if (/[.,]/.test(unsignedInput)) {
const lastDecimalIndex = unsignedInput.lastIndexOf(decimalSeparator);
const lastFallbackIndex = unsignedInput.lastIndexOf(fallbackSeparator);

if (lastDecimalIndex >= 0 && lastFallbackIndex >= 0) {
const decimalIndex = Math.max(lastDecimalIndex, lastFallbackIndex);
return {
sign,
integerPart: normalizeGroupedValue(
unsignedInput.slice(0, decimalIndex)
),
decimalPart: unsignedInput
.slice(decimalIndex + 1)
.replace(/[.,]/g, ''),
hasExplicitDecimal: true
};
}

if (unsignedInput.endsWith(decimalSeparator)) {
return {
sign,
integerPart: normalizeGroupedValue(unsignedInput.slice(0, -1)),
decimalPart: '',
hasExplicitDecimal: true
};
}

if (unsignedInput.includes(decimalSeparator)) {
return splitOnSeparator(decimalSeparator)!;
}

if (unsignedInput.includes(groupSeparator)) {
const parts = unsignedInput.split(groupSeparator);
const [integerPart, decimalPart = ''] = parts;
const isDecimalFallback =
parts.length === 2 &&
(integerPart === '0' || decimalPart.length !== 3);

if (isDecimalFallback) {
return {
sign,
integerPart: normalizeGroupedValue(integerPart),
decimalPart: decimalPart.replace(/[.,]/g, ''),
hasExplicitDecimal: true
};
}

return {
sign,
integerPart: normalizeGroupedValue(parts.join(groupSeparator)),
decimalPart: undefined,
hasExplicitDecimal: false
};
}

if (unsignedInput.endsWith(fallbackSeparator)) {
return {
sign,
integerPart: normalizeGroupedValue(unsignedInput.slice(0, -1)),
decimalPart: '',
hasExplicitDecimal: true
};
}

if (unsignedInput.includes(fallbackSeparator)) {
const parts = unsignedInput.split(fallbackSeparator);
const [integerPart, decimalPart = ''] = parts;
const isGroupedFallback =
parts.length > 2 ||
(parts.length === 2 &&
decimalPart.length === 3 &&
integerPart.length > 0 &&
integerPart !== '0');

if (isGroupedFallback) {
return {
sign,
integerPart: normalizeGroupedValue(
parts.join(fallbackSeparator)
),
decimalPart: undefined,
hasExplicitDecimal: false
};
}

return {
sign,
integerPart: normalizeGroupedValue(integerPart),
decimalPart: decimalPart.replace(/[.,]/g, ''),
hasExplicitDecimal: true
};
}
}

return {
sign,
integerPart: unsignedInput.replace(/[.,]/g, '') || '0',
decimalPart: undefined,
hasExplicitDecimal: false
};
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This function splitNumericValue is quite complex due to the various heuristics for parsing locale-specific number formats. While it appears to handle many cases correctly, its complexity could make it difficult to maintain or debug in the future.

To improve maintainability, please consider adding comments to explain the logic, especially for the fallback heuristics like isDecimalFallback and isGroupedFallback. Explaining why these checks are structured the way they are (e.g., why decimalPart.length !== 3 is a deciding factor) would be very helpful for future developers.

Comment thread components/Amount.tsx
Comment on lines +363 to +404
componentDidMount() {
this.ensureFiatRatesAvailable();
}

componentDidUpdate(prevProps: Readonly<AmountProps>) {
if (
prevProps.fixedUnits !== this.props.fixedUnits ||
prevProps.sats !== this.props.sats ||
prevProps.FiatStore?.fiatRates !==
this.props.FiatStore?.fiatRates ||
prevProps.SettingsStore?.settings.fiat !==
this.props.SettingsStore?.settings.fiat
) {
this.ensureFiatRatesAvailable();
}
}

ensureFiatRatesAvailable = () => {
const FiatStore = this.props.FiatStore!;
const UnitsStore = this.props.UnitsStore!;
const SettingsStore = this.props.SettingsStore!;
const units = this.props.fixedUnits || UnitsStore.units;
const selectedFiat = SettingsStore.settings?.fiat;

if (
units !== 'fiat' ||
!SettingsStore.settings?.fiatEnabled ||
!selectedFiat ||
FiatStore.loading
) {
return;
}

const hasSelectedFiatRate = FiatStore.fiatRates?.some(
(entry: any) => entry.code === selectedFiat
);

if (!hasSelectedFiatRate) {
FiatStore.getFiatRates();
}
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why are these changes needed?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These changes were added because, while testing this PR, I found that some places render before the selected fiat rate is available in FiatStore, which causes the component to show N/A even though the formatting logic is correct. The new guard only runs when the component is actually rendering fiat, fiat is enabled, a fiat currency is selected, the selected rate is missing, and a fetch is not already in progress, so it is just a defensive way to avoid that missing-rate state in amount/conversion flows that do not always go through the main wallet refresh path.

@kaloudis
Copy link
Copy Markdown
Contributor

I don't think this logic should apply to fiat currencies, which have their own standards for comma vs decimal formatting, that we already define very clearly in the app.

I think we should only apply this for Bitcoin and satoshi amounts. What do you think @myxmaster?

@myxmaster
Copy link
Copy Markdown
Collaborator

First of all thanks a lot @cherry-1729-9090 for taking the time and working on this, it is a pretty important issue imo.

I did not test this yet or looked into the code.

But concept-wise, I strongly suggest keeping sats/₿ and fiat in line, meaning: when the app language is English, use English thousands and decimal separators for everything.

The formatting should follow the reader's locale, not the currency. Mixing conventions within the same screen is cognitively jarring and can lead to misreads. (Example: When you're on vacation in Europe and see € 1.021,34, would that feel intuitive to you, or would you prefer € 1,021.34 right next to your 1,755,264 sats?)

@cherry-1729-9090
Copy link
Copy Markdown
Contributor Author

Thanks a lot for the feedback, really appreciate it.
I agree with your point, separators should follow the app language (reader locale), not the currency.
So if app language is English: 1,234.5, and if it’s German: 1.234,5, consistently across sats, BTC, and fiat.

That’s the approach implemented in this PR to avoid mixed formatting on the same screen and reduce misreads.

@kaloudis kaloudis added this to the v13.1.0 milestone Apr 8, 2026
@cherry-1729-9090 cherry-1729-9090 force-pushed the feature/app-language-number-separators branch from f4b259c to c00b779 Compare April 11, 2026 06:04
@cherry-1729-9090 cherry-1729-9090 force-pushed the feature/app-language-number-separators branch from c00b779 to 2ad4c34 Compare April 18, 2026 06:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants