Compare commits

...

5 Commits

Author SHA1 Message Date
Overtorment
062507a8d3 TST: improve integration tests flakiness and verbosity 2026-05-20 22:19:54 +01:00
Nuno
74d9e3d024
Merge branch 'master' into fix-inputamount 2026-05-20 22:04:18 +02:00
Nuno
e720e402da
Merge branch 'master' into fix-inputamount 2026-05-20 18:25:05 +02:00
ncoelho
eeb2f96a7f add animation to avoid jumpyness 2026-05-20 18:24:40 +02:00
ncoelho
2b6165db82 fix: center amount input 2026-05-19 21:59:58 +02:00
4 changed files with 197 additions and 132 deletions

View File

@ -1,9 +1,11 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import Animated, { Easing, FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated';
import Clipboard from '@react-native-clipboard/clipboard';
import {
Text,
Image,
NativeSyntheticEvent,
Platform,
Pressable,
StyleSheet,
TextInput,
@ -43,6 +45,23 @@ export const setCachedSatoshis = (amount: string, sats: string): void => {
conversionCache[amount + BitcoinUnit.LOCAL_CURRENCY] = sats;
};
const INPUT_HORIZONTAL_PADDING = 6;
const INPUT_VERTICAL_PADDING = 2;
const MAX_INPUT_WIDTH = 320;
const CRYPTO_CONTAINER_OFFSET = -12;
const SWAP_ICON_SIZE = 24;
const CHAR_FADE_IN_DURATION_MS = 240;
const CHAR_FADE_OUT_DURATION_MS = 160;
const CHAR_LAYOUT_DURATION_MS = 180;
const SIZER_LAYOUT_DURATION_MS = 200;
const androidFontPaddingStyle = Platform.OS === 'android' ? { includeFontPadding: false } : null;
const sizerLayoutTransition = LinearTransition.duration(SIZER_LAYOUT_DURATION_MS).easing(Easing.out(Easing.quad));
const charLayoutTransition = LinearTransition.duration(CHAR_LAYOUT_DURATION_MS).easing(Easing.out(Easing.quad));
const charEntering = FadeIn.duration(CHAR_FADE_IN_DURATION_MS);
const charExiting = FadeOut.duration(CHAR_FADE_OUT_DURATION_MS);
type AmountInputProps = Omit<TextInputProps, 'onChangeText' | 'value'> & {
/**
* Whether the input is in a loading state
@ -120,12 +139,12 @@ export const AmountInput: React.FC<AmountInputProps> = props => {
const inputFontSize = useMemo(() => (amount.length > 10 ? 20 : 36), [amount.length]);
const inputWidth = useMemo(() => {
const valueLength = Math.max((displayAmount || '0').length, 1);
const estimatedCharWidth = inputFontSize * 0.68;
const calculatedWidth = Math.ceil(valueLength * estimatedCharWidth) + 12;
return Math.min(Math.max(calculatedWidth, 32), 320);
}, [displayAmount, inputFontSize]);
const measureAmountText = displayAmount && displayAmount.length > 0 ? displayAmount : '0';
const inputTextAlign = useMemo((): 'left' | 'right' | 'center' => {
if (amount === BitcoinUnit.MAX) return 'center';
return unit === BitcoinUnit.LOCAL_CURRENCY ? 'left' : 'right';
}, [amount, unit]);
const secondaryDisplayCurrency = useMemo(() => {
if (amount === BitcoinUnit.MAX) {
@ -195,7 +214,6 @@ export const AmountInput: React.FC<AmountInputProps> = props => {
* here we must recalculate old amont value (which was denominated in `previousUnit`) to new denomination `newUnit`
* and fill this value in input box, so user can switch between, for example, 0.001 BTC <=> 100000 sats
*/
const log = `${amount}(${previousUnit}) ->`;
let sats: string = '0';
switch (previousUnit) {
case BitcoinUnit.BTC:
@ -214,7 +232,6 @@ export const AmountInput: React.FC<AmountInputProps> = props => {
}
const newInputValue = formatBalancePlain(+sats, newUnit, false);
console.log(`${log} ${sats}(sats) -> ${newInputValue}(${newUnit})`);
if (newUnit === BitcoinUnit.LOCAL_CURRENCY && previousUnit === BitcoinUnit.SATS) {
// we cache conversion, so when we will need reverse conversion there wont be a rounding error
@ -293,46 +310,124 @@ export const AmountInput: React.FC<AmountInputProps> = props => {
[amount],
);
const isCryptoUnit = unit !== BitcoinUnit.LOCAL_CURRENCY;
const amountCharacters = useMemo(() => measureAmountText.split(''), [measureAmountText]);
const displayJustifyContent = useMemo((): 'flex-start' | 'flex-end' | 'center' => {
if (inputTextAlign === 'right') return 'flex-end';
if (inputTextAlign === 'left') return 'flex-start';
return 'center';
}, [inputTextAlign]);
const inputTextColor = disabled ? colors.buttonDisabledTextColor : colors.alternativeTextColor2;
const hiddenInputTextColor = Platform.OS === 'android' ? `${inputTextColor}00` : 'transparent';
const inputTypography = {
fontSize: inputFontSize,
lineHeight: Math.round(inputFontSize * 1.15),
minHeight: Math.round(inputFontSize * 1.15) + INPUT_VERTICAL_PADDING * 2,
textAlign: inputTextAlign,
...(isCryptoUnit && {
paddingLeft: INPUT_HORIZONTAL_PADDING + 4,
}),
};
const stylesHook = {
center: { padding: amount === BitcoinUnit.MAX ? 0 : 15 },
localCurrency: { color: disabled ? colors.buttonDisabledTextColor : colors.alternativeTextColor2 },
input: {
color: disabled ? colors.buttonDisabledTextColor : colors.alternativeTextColor2,
fontSize: inputFontSize,
lineHeight: inputFontSize,
minHeight: inputFontSize + 8,
width: inputWidth,
container: {
marginLeft: unit === BitcoinUnit.LOCAL_CURRENCY ? 0 : CRYPTO_CONTAINER_OFFSET,
overflow: 'visible' as const,
},
cryptoCurrency: { color: disabled ? colors.buttonDisabledTextColor : colors.alternativeTextColor2 },
localCurrency: { color: inputTextColor },
input: {
color: inputTextColor,
...inputTypography,
},
inputField: inputTypography,
inputDisplay: {
justifyContent: displayJustifyContent,
paddingHorizontal: INPUT_HORIZONTAL_PADDING,
paddingVertical: INPUT_VERTICAL_PADDING,
...(isCryptoUnit && {
paddingLeft: INPUT_HORIZONTAL_PADDING + 4,
}),
},
inputGlyph: {
color: inputTextColor,
fontSize: inputTypography.fontSize,
lineHeight: inputTypography.lineHeight,
},
inputTransparent: {
color: hiddenInputTextColor,
},
cryptoCurrency: { color: inputTextColor },
};
return (
<Pressable accessibilityRole="button" accessibilityLabel={loc._.enter_amount} disabled={disabled} onPress={handleTextInputOnPress}>
<View style={styles.root}>
{!disabled && <View style={[styles.center, stylesHook.center]} />}
{!disabled && <View style={styles.sideRail} />}
<View style={styles.flex}>
<View style={styles.container}>
<View style={[styles.container, stylesHook.container]}>
{unit === BitcoinUnit.LOCAL_CURRENCY && amount !== BitcoinUnit.MAX && (
<Text style={[styles.localCurrency, stylesHook.localCurrency]}>{getCurrencySymbol() + ' '}</Text>
<Text style={[styles.localCurrency, stylesHook.localCurrency]}>{getCurrencySymbol()}</Text>
)}
{amount !== BitcoinUnit.MAX ? (
<TextInput
onSelectionChange={handleSelectionChange}
testID="BitcoinAmountInput"
keyboardType="numeric"
onChangeText={handleChangeText}
placeholder="0"
maxLength={maxLength}
ref={textInputRef}
editable={!isLoading && !disabled}
value={displayAmount}
placeholderTextColor={disabled ? colors.buttonDisabledTextColor : colors.alternativeTextColor2}
style={[styles.input, stylesHook.input]}
{...otherProps}
/>
<Animated.View layout={sizerLayoutTransition} style={styles.inputSizer}>
<Text
style={[styles.input, styles.inputMeasure, stylesHook.input, androidFontPaddingStyle]}
numberOfLines={1}
allowFontScaling={false}
accessible={false}
importantForAccessibility="no-hide-descendants"
>
{measureAmountText}
</Text>
<Animated.View layout={charLayoutTransition} style={[styles.inputDisplay, stylesHook.inputDisplay]} pointerEvents="none">
{amountCharacters.map((char, index) => (
<Animated.Text
key={`${index}-${char}`}
entering={charEntering}
exiting={charExiting}
layout={charLayoutTransition}
allowFontScaling={false}
style={[styles.inputGlyph, stylesHook.inputGlyph, androidFontPaddingStyle]}
>
{char}
</Animated.Text>
))}
</Animated.View>
<TextInput
{...otherProps}
allowFontScaling={false}
underlineColorAndroid="transparent"
onSelectionChange={handleSelectionChange}
testID="BitcoinAmountInput"
keyboardType="numeric"
onChangeText={handleChangeText}
placeholder="0"
maxLength={maxLength}
ref={textInputRef}
editable={!isLoading && !disabled}
value={displayAmount}
placeholderTextColor={inputTextColor}
cursorColor={inputTextColor}
selectionColor={inputTextColor}
style={[
styles.input,
styles.inputOverlay,
stylesHook.inputField,
stylesHook.inputTransparent,
androidFontPaddingStyle,
otherProps.style,
]}
/>
</Animated.View>
) : (
<Pressable onPress={resetAmount} style={styles.maxPressable}>
<Text style={[styles.input, stylesHook.input]}>{BitcoinUnit.MAX}</Text>
<Text numberOfLines={1} style={[styles.input, styles.maxLabel, stylesHook.input]}>
{BitcoinUnit.MAX}
</Text>
{maxSendableAmount != null && (
<Text style={[styles.maxEstimate, stylesHook.localCurrency]} onLongPress={copyMaxEstimate}>
{(isMaxAmountEstimate ? '≈ ' : '') +
@ -344,7 +439,7 @@ export const AmountInput: React.FC<AmountInputProps> = props => {
</Pressable>
)}
{unit !== BitcoinUnit.LOCAL_CURRENCY && amount !== BitcoinUnit.MAX && (
<Text style={[styles.cryptoCurrency, stylesHook.cryptoCurrency]}>{' ' + loc.units[unit]}</Text>
<Text style={[styles.cryptoCurrency, stylesHook.cryptoCurrency]}>{loc.units[unit]}</Text>
)}
</View>
<View style={styles.secondaryRoot}>
@ -353,17 +448,20 @@ export const AmountInput: React.FC<AmountInputProps> = props => {
</Text>
</View>
</View>
{!disabled && amount !== BitcoinUnit.MAX && (
<TouchableOpacity
accessibilityRole="button"
accessibilityLabel={loc._.change_input_currency}
testID="changeAmountUnitButton"
style={styles.changeAmountUnit}
onPress={changeAmountUnit}
>
<Image source={require('../img/round-compare-arrows-24-px.png')} />
</TouchableOpacity>
)}
{!disabled &&
(amount !== BitcoinUnit.MAX ? (
<TouchableOpacity
accessibilityRole="button"
accessibilityLabel={loc._.change_input_currency}
testID="changeAmountUnitButton"
style={[styles.sideRail, styles.changeAmountUnit]}
onPress={changeAmountUnit}
>
<Image source={require('../img/round-compare-arrows-24-px.png')} />
</TouchableOpacity>
) : (
<View style={styles.sideRail} />
))}
</View>
{outdatedRefreshRate && (
<View style={styles.outdatedRateContainer}>
@ -391,11 +489,15 @@ const styles = StyleSheet.create({
flexDirection: 'row',
justifyContent: 'space-between',
},
center: {
alignSelf: 'center',
},
flex: {
flex: 1,
overflow: 'visible',
},
sideRail: {
width: SWAP_ICON_SIZE,
alignItems: 'center',
justifyContent: 'center',
alignSelf: 'center',
},
spacing8: {
width: 8,
@ -425,23 +527,48 @@ const styles = StyleSheet.create({
justifyContent: 'center',
paddingTop: 16,
paddingBottom: 2,
overflow: 'visible',
},
localCurrency: {
fontSize: 18,
marginHorizontal: 4,
marginRight: 2,
fontWeight: 'bold',
alignSelf: 'center',
justifyContent: 'center',
},
inputSizer: {
maxWidth: MAX_INPUT_WIDTH,
position: 'relative',
overflow: 'visible',
},
input: {
fontWeight: 'bold',
paddingHorizontal: 0,
paddingVertical: 0,
textAlign: 'center',
margin: 0,
borderWidth: 0,
paddingHorizontal: INPUT_HORIZONTAL_PADDING,
paddingVertical: INPUT_VERTICAL_PADDING,
},
inputGlyph: {
fontWeight: 'bold',
margin: 0,
padding: 0,
},
inputMeasure: {
opacity: 0,
},
inputDisplay: {
...StyleSheet.absoluteFill,
flexDirection: 'row',
alignItems: 'center',
zIndex: 2,
},
inputOverlay: {
...StyleSheet.absoluteFill,
zIndex: 1,
},
cryptoCurrency: {
fontSize: 15,
marginHorizontal: 4,
marginLeft: 2,
fontWeight: '600',
alignSelf: 'center',
justifyContent: 'center',
@ -462,11 +589,12 @@ const styles = StyleSheet.create({
},
maxPressable: {
alignItems: 'center',
flexShrink: 0,
},
maxLabel: {
flexShrink: 0,
},
changeAmountUnit: {
alignSelf: 'center',
marginRight: 16,
paddingLeft: 16,
paddingVertical: 16,
},
});

View File

@ -205,79 +205,6 @@ describe.skip('LightningCustodianWallet', () => {
// transactions became more after paying an invoice
});
// turned off because acinq strike is shutting down
// eslint-disable-next-line jest/no-disabled-tests
it.skip('can pay invoice (acinq)', async () => {
if (!process.env.BLITZHUB) {
console.error('process.env.BLITZHUB not set, skipped');
return;
}
if (!process.env.STRIKE) {
console.error('process.env.STRIKE not set, skipped');
return;
}
const response = await fetch('https://api.strike.acinq.co/api/v1/charges', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Basic ${btoa(process.env.STRIKE + ':')}`,
},
body: 'amount=1&currency=btc&description=acceptance+test',
});
const res = await response.json();
if (!res.payment_request) {
throw new Error('Strike problem: ' + JSON.stringify(res));
}
const invoice = res.payment_request;
const l2 = new LightningCustodianWallet();
l2.setSecret(process.env.BLITZHUB);
l2.setBaseURI(baseUri);
l2.init();
await l2.authorize();
await l2.fetchTransactions();
const txLen = l2.transactions_raw.length;
const decoded = l2.decodeInvoice(invoice);
assert.ok(decoded.payment_hash);
assert.ok(decoded.description);
let start = +new Date();
await l2.payInvoice(invoice);
let end = +new Date();
if ((end - start) / 1000 > 9) {
console.warn('payInvoice took', (end - start) / 1000, 'sec');
}
await l2.fetchTransactions();
assert.strictEqual(l2.transactions_raw.length, txLen + 1);
const lastTx = l2.transactions_raw[l2.transactions_raw.length - 1];
assert.strictEqual(typeof lastTx.payment_preimage, 'string', 'preimage is present and is a string');
assert.strictEqual(lastTx.payment_preimage.length, 64, 'preimage is present and is a string of 32 hex-encoded bytes');
// transactions became more after paying an invoice
// now, trying to pay duplicate invoice
start = +new Date();
let caughtError = false;
try {
await l2.payInvoice(invoice);
} catch (Err) {
caughtError = true;
}
assert.ok(caughtError);
await l2.fetchTransactions();
assert.strictEqual(l2.transactions_raw.length, txLen + 1);
// havent changed since last time
end = +new Date();
if ((end - start) / 1000 > 9) {
console.warn('duplicate payInvoice took', (end - start) / 1000, 'sec');
}
});
it('can pay invoice (bitrefill)', async () => {
if (!process.env.BLITZHUB) {
console.error('process.env.BLITZHUB not set, skipped');

View File

@ -19,7 +19,7 @@ beforeAll(async () => {
describe('Watch only wallet', () => {
it('can fetch balance', async () => {
const w = new WatchOnlyWallet();
w.setSecret('1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa');
w.setSecret('12cbQLTFMXRnSzktFkuoG3eHoMeFtpTu3S');
await w.fetchBalance();
assert.ok(w.getBalance() > 16);
});

View File

@ -22,6 +22,16 @@ console.debug = console.log = (...args) => {
args[0].startsWith('Created new currency formatter for') ||
args[0].startsWith('begin connection') ||
args[0].startsWith('TLS Connected to') ||
args[0].startsWith('Getting saved peer') ||
args[0].startsWith('Getting Electrum connection disabled state') ||
args[0].startsWith('Using peer') ||
args[0].startsWith('fetch wrapper') ||
args[0].startsWith('Preferred currency') ||
args[0].startsWith('SelfTest - runSelfTest') ||
args[0].startsWith('Wallet.create() took') ||
args[0].startsWith('Cleared all cached currency formatters') ||
args[0].startsWith('[UnitSwitch/Fiat]') ||
args[0].startsWith('transactionDetail - useEffect') ||
args[0].startsWith('connected to'))
) {
return;