367 lines
11 KiB
TypeScript
367 lines
11 KiB
TypeScript
import { Animated } from 'react-native';
|
|
import BigNumber from 'bignumber.js';
|
|
|
|
import FiatStore from '../stores/FiatStore';
|
|
import SettingsStore from '../stores/SettingsStore';
|
|
|
|
import type { Units } from './UnitsUtils';
|
|
|
|
/**
|
|
* Returns the maximum number of decimal places allowed for the current unit.
|
|
* - sats: 3 decimal places (millisats)
|
|
* - BTC: 8 decimal places
|
|
* - fiat: varies by currency (typically 2, some currencies have 0)
|
|
*/
|
|
export const getDecimalLimit = (
|
|
units: Units | string,
|
|
fiatStore: FiatStore,
|
|
settingsStore: SettingsStore
|
|
): number | null => {
|
|
if (units === 'fiat') {
|
|
const fiat = settingsStore.settings?.fiat || '';
|
|
const fiatProperties = fiatStore.symbolLookup(fiat);
|
|
return fiatProperties?.decimalPlaces !== undefined
|
|
? fiatProperties.decimalPlaces
|
|
: 2;
|
|
}
|
|
if (units === 'sats') return 3;
|
|
if (units === 'BTC') return 8;
|
|
return null;
|
|
};
|
|
|
|
/**
|
|
* Validates keypad input and returns whether the input is valid and the new amount.
|
|
* Handles:
|
|
* - Decimal place limits based on unit
|
|
* - BTC integer part length limit (8 figures)
|
|
* - Single decimal point enforcement
|
|
* - Maximum capacity checks (21M BTC, 2.1 quadrillion sats)
|
|
*/
|
|
export const validateKeypadInput = (
|
|
currentAmount: string,
|
|
inputValue: string,
|
|
units: Units | string,
|
|
fiatStore: FiatStore,
|
|
settingsStore: SettingsStore
|
|
): { valid: boolean; newAmount: string } => {
|
|
const decimalLimit = getDecimalLimit(units, fiatStore, settingsStore);
|
|
const [integerPart, decimalPart] = currentAmount.split('.');
|
|
|
|
// Limit decimal places depending on units
|
|
if (
|
|
decimalPart &&
|
|
decimalLimit !== null &&
|
|
decimalPart.length >= decimalLimit
|
|
) {
|
|
return { valid: false, newAmount: currentAmount };
|
|
}
|
|
|
|
// For BTC: deny if trying to add more than 8 figures before decimal
|
|
if (units === 'BTC') {
|
|
if (
|
|
!decimalPart &&
|
|
integerPart &&
|
|
integerPart.length === 8 &&
|
|
!currentAmount.includes('.') &&
|
|
inputValue !== '.'
|
|
) {
|
|
return { valid: false, newAmount: currentAmount };
|
|
}
|
|
}
|
|
|
|
// For sats: deny if trying to add more than 12 figures before decimal
|
|
if (units === 'sats') {
|
|
if (
|
|
!decimalPart &&
|
|
integerPart &&
|
|
integerPart.length >= 12 &&
|
|
!currentAmount.includes('.') &&
|
|
inputValue !== '.'
|
|
) {
|
|
return { valid: false, newAmount: currentAmount };
|
|
}
|
|
}
|
|
|
|
// For fiat: deny if trying to add more than 10 figures before decimal
|
|
if (units === 'fiat') {
|
|
if (
|
|
!decimalPart &&
|
|
integerPart &&
|
|
integerPart.length >= 10 &&
|
|
!currentAmount.includes('.') &&
|
|
inputValue !== '.'
|
|
) {
|
|
return { valid: false, newAmount: currentAmount };
|
|
}
|
|
}
|
|
|
|
// Only allow one decimal, unless currency has zero decimal places
|
|
if (
|
|
inputValue === '.' &&
|
|
(currentAmount.includes('.') || decimalLimit === 0)
|
|
) {
|
|
return { valid: false, newAmount: currentAmount };
|
|
}
|
|
|
|
const proposedNewAmountStr = `${currentAmount}${inputValue}`;
|
|
const proposedNewAmount = new BigNumber(proposedNewAmountStr);
|
|
|
|
// Deny if exceeding BTC 21 million capacity
|
|
if (units === 'BTC' && proposedNewAmount.gt(21000000)) {
|
|
return { valid: false, newAmount: currentAmount };
|
|
}
|
|
if (units === 'sats' && proposedNewAmount.gt(2100000000000000)) {
|
|
return { valid: false, newAmount: currentAmount };
|
|
}
|
|
|
|
// Determine new amount
|
|
let newAmount: string;
|
|
if (currentAmount === '0') {
|
|
newAmount = inputValue === '.' ? '0.' : inputValue;
|
|
} else {
|
|
newAmount = proposedNewAmountStr;
|
|
}
|
|
|
|
return { valid: true, newAmount };
|
|
};
|
|
|
|
/**
|
|
* Calculate font size for amount display based on length.
|
|
* @param amountLength - Length of the amount string
|
|
* @param placeholderCount - Number of placeholder characters
|
|
* @param options.compact - Use smaller sizes (for POS view with additional UI elements)
|
|
* @param options.needInbound - Reduce sizes for LSP inbound capacity warning
|
|
*/
|
|
export const getAmountFontSize = (
|
|
amountLength: number,
|
|
placeholderCount: number,
|
|
options: { compact?: boolean; needInbound?: boolean } = {}
|
|
): number => {
|
|
const { compact = false, needInbound = false } = options;
|
|
const totalLength = amountLength + placeholderCount;
|
|
|
|
if (compact) {
|
|
switch (totalLength) {
|
|
case 1:
|
|
case 2:
|
|
return 80;
|
|
case 3:
|
|
case 4:
|
|
return 65;
|
|
case 5:
|
|
case 6:
|
|
return 55;
|
|
case 7:
|
|
return 50;
|
|
case 8:
|
|
return 45;
|
|
default:
|
|
return 35;
|
|
}
|
|
}
|
|
|
|
// Standard sizing with optional inbound adjustment
|
|
switch (totalLength) {
|
|
case 1:
|
|
case 2:
|
|
case 3:
|
|
case 4:
|
|
return needInbound ? 70 : 80;
|
|
case 5:
|
|
return needInbound ? 65 : 75;
|
|
case 6:
|
|
return needInbound ? 60 : 65;
|
|
case 7:
|
|
return needInbound ? 55 : 60;
|
|
case 8:
|
|
return needInbound ? 50 : 55;
|
|
case 9:
|
|
return needInbound ? 45 : 50;
|
|
default:
|
|
return needInbound ? 40 : 45;
|
|
}
|
|
};
|
|
|
|
export interface KeypadAnimationRefs {
|
|
textAnimationRef: Animated.CompositeAnimation | null;
|
|
shakeAnimationRef: Animated.CompositeAnimation | null;
|
|
}
|
|
|
|
export const resetKeypadTextAnimation = (
|
|
textAnimation: Animated.Value,
|
|
refs: KeypadAnimationRefs
|
|
): void => {
|
|
if (refs.textAnimationRef) {
|
|
refs.textAnimationRef.stop();
|
|
refs.textAnimationRef = null;
|
|
}
|
|
textAnimation.setValue(0);
|
|
};
|
|
|
|
export const stopKeypadShakeAnimation = (refs: KeypadAnimationRefs): void => {
|
|
if (refs.shakeAnimationRef) {
|
|
refs.shakeAnimationRef.stop();
|
|
refs.shakeAnimationRef = null;
|
|
}
|
|
};
|
|
|
|
export const resetAllKeypadAnimations = (
|
|
shakeAnimation: Animated.Value,
|
|
textAnimation: Animated.Value,
|
|
refs: KeypadAnimationRefs
|
|
): void => {
|
|
resetKeypadTextAnimation(textAnimation, refs);
|
|
stopKeypadShakeAnimation(refs);
|
|
shakeAnimation.setValue(0);
|
|
};
|
|
|
|
export const startKeypadInvalidInputAnimation = (
|
|
shakeAnimation: Animated.Value,
|
|
textAnimation: Animated.Value,
|
|
refs: KeypadAnimationRefs
|
|
): void => {
|
|
resetKeypadTextAnimation(textAnimation, refs);
|
|
stopKeypadShakeAnimation(refs);
|
|
|
|
// This animates text color (text -> red), and color is not supported
|
|
// by the RN Animated native driver. This is why useNativeDriver is false.
|
|
refs.textAnimationRef = Animated.sequence([
|
|
Animated.timing(textAnimation, {
|
|
toValue: 1,
|
|
duration: 100,
|
|
useNativeDriver: false
|
|
}),
|
|
Animated.timing(textAnimation, {
|
|
toValue: 0,
|
|
duration: 1000,
|
|
useNativeDriver: false
|
|
})
|
|
]);
|
|
|
|
refs.shakeAnimationRef = Animated.sequence([
|
|
Animated.timing(shakeAnimation, {
|
|
toValue: 10,
|
|
duration: 100,
|
|
useNativeDriver: true
|
|
}),
|
|
Animated.timing(shakeAnimation, {
|
|
toValue: -10,
|
|
duration: 100,
|
|
useNativeDriver: true
|
|
}),
|
|
Animated.timing(shakeAnimation, {
|
|
toValue: 10,
|
|
duration: 100,
|
|
useNativeDriver: true
|
|
}),
|
|
Animated.timing(shakeAnimation, {
|
|
toValue: 0,
|
|
duration: 100,
|
|
useNativeDriver: true
|
|
})
|
|
]);
|
|
|
|
Animated.parallel([refs.textAnimationRef, refs.shakeAnimationRef]).start(
|
|
() => {
|
|
refs.textAnimationRef = null;
|
|
refs.shakeAnimationRef = null;
|
|
}
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Deletes the last character from the amount string.
|
|
* Returns '0' if the amount would become empty.
|
|
*/
|
|
export const deleteLastCharacter = (amount: string): string => {
|
|
if (amount.length === 1) {
|
|
return '0';
|
|
}
|
|
return amount.substring(0, amount.length - 1);
|
|
};
|
|
|
|
/**
|
|
* Parses a free-form clipboard string into a keypad-compatible amount in the
|
|
* given unit. Strips currency symbols, unit suffixes ("sats", "BTC", ...) and
|
|
* thousands separators, honours the decimal separator implied by the active
|
|
* currency (e.g. "1.234,56" for EUR), truncates excess decimal places, and
|
|
* enforces the same integer-length and capacity caps as keypad entry.
|
|
*
|
|
* Returns null when the clipboard does not contain a usable numeric amount.
|
|
*/
|
|
export const parseClipboardAmount = (
|
|
clipboardValue: string,
|
|
units: Units | string,
|
|
fiatStore: FiatStore,
|
|
settingsStore: SettingsStore
|
|
): string | null => {
|
|
if (!clipboardValue) return null;
|
|
|
|
// Reject clipboard content that obviously isn't a number — e.g. BOLT11
|
|
// invoices, on-chain addresses, or BIP-21 URIs would otherwise have their
|
|
// alphabetic chars stripped and the leftover digits parsed as an amount.
|
|
// Strip recognised unit/currency labels first so "100 USD" or "50000 sats"
|
|
// pass the alphabetic-content check.
|
|
let withoutUnitLabels = clipboardValue.replace(
|
|
/sats?|satoshis?|btc|bitcoin|msats?/gi,
|
|
''
|
|
);
|
|
const activeFiat = settingsStore?.settings?.fiat;
|
|
if (activeFiat) {
|
|
withoutUnitLabels = withoutUnitLabels.replace(
|
|
new RegExp(`\\b${activeFiat}\\b`, 'gi'),
|
|
''
|
|
);
|
|
}
|
|
if (/[a-z]{2,}/i.test(withoutUnitLabels)) return null;
|
|
|
|
const useCommaAsDecimal =
|
|
units === 'fiat' && !!fiatStore.getSymbol().separatorSwap;
|
|
|
|
let cleaned = clipboardValue.trim();
|
|
if (useCommaAsDecimal) {
|
|
cleaned = cleaned.replace(/\./g, '').replace(/,/g, '.');
|
|
} else {
|
|
cleaned = cleaned.replace(/,/g, '');
|
|
}
|
|
cleaned = cleaned.replace(/[^0-9.]/g, '');
|
|
|
|
const firstDot = cleaned.indexOf('.');
|
|
if (firstDot !== -1) {
|
|
cleaned =
|
|
cleaned.substring(0, firstDot + 1) +
|
|
cleaned.substring(firstDot + 1).replace(/\./g, '');
|
|
}
|
|
|
|
if (!cleaned || cleaned === '.') return null;
|
|
|
|
const parts = cleaned.split('.');
|
|
let intPart = parts[0].replace(/^0+/, '') || '0';
|
|
let decPart: string | undefined = parts[1];
|
|
|
|
const decimalLimit = getDecimalLimit(units, fiatStore, settingsStore);
|
|
if (decPart !== undefined) {
|
|
if (decimalLimit === 0) {
|
|
decPart = undefined;
|
|
} else if (decimalLimit !== null && decPart.length > decimalLimit) {
|
|
decPart = decPart.substring(0, decimalLimit);
|
|
}
|
|
}
|
|
|
|
if (units === 'BTC' && intPart.length > 8) return null;
|
|
if (units === 'sats' && intPart.length > 12) return null;
|
|
if (units === 'fiat' && intPart.length > 10) return null;
|
|
|
|
const finalAmount =
|
|
decPart !== undefined && decPart !== ''
|
|
? `${intPart}.${decPart}`
|
|
: intPart;
|
|
|
|
const bn = new BigNumber(finalAmount);
|
|
if (bn.isNaN()) return null;
|
|
if (units === 'BTC' && bn.gt(21000000)) return null;
|
|
if (units === 'sats' && bn.gt(2100000000000000)) return null;
|
|
|
|
return finalAmount;
|
|
};
|