Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions src/components/AmountDisplay.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* Copyright (c) Hathor Labs and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

import React, { useState } from 'react';
import { Text } from 'react-native';
import {
computeAmountFontFit,
AMOUNT_FONT_BASE_SIZE,
AMOUNT_FONT_MIN_SIZE,
AMOUNT_MAX_LINES,
} from '../utils';

// Line height as a fraction of font size; matches AmountTextInput's
// LINE_HEIGHT_RATIO so input and read-only display wrap identically.
const LINE_HEIGHT_RATIO = 1.2;

/**
* Read-only counterpart to AmountTextInput (transaction detail balance, home
* balance, ...), using the same shrink-first-then-wrap ladder (`computeAmountFontFit`).
*
* Intentionally NOT `<Text adjustsFontSizeToFit numberOfLines={3}>`: on iOS that
* wraps at the base size WITHOUT shrinking, so a long amount fills three full-size lines.
*
* Measures its own width via `onLayout`, so callers MUST let it stretch (defaults to
* `alignSelf: 'stretch'`); sizing to content instead feeds the measurement back on itself.
*
* @param {Object} props
* @param {string} props.children - The amount string to render (value plus symbol)
* @param {Object} [props.style] - Additional text styles (color, fontWeight, ...)
* @param {number} [props.baseFontSize] - Starting (largest) font size
* @param {number} [props.minFontSize] - Floor font size before wrapping
* @returns {React.ReactElement}
*/
const AmountDisplay = ({
children,
style,
baseFontSize = AMOUNT_FONT_BASE_SIZE,
minFontSize = AMOUNT_FONT_MIN_SIZE,
...rest
}) => {
const [availableWidth, setAvailableWidth] = useState(0);
const content = children == null ? '' : String(children);
const { fontSize } = computeAmountFontFit(
content.length,
availableWidth,
baseFontSize,
minFontSize,
);
const lineHeight = Math.round(fontSize * LINE_HEIGHT_RATIO);

return (
<Text
numberOfLines={AMOUNT_MAX_LINES}
onLayout={(e) => setAvailableWidth(e.nativeEvent.layout.width)}
style={[
{ alignSelf: 'stretch', textAlign: 'center' },
style,
{ fontSize, lineHeight },
]}
{...rest}
>
{content}
</Text>
);
};

export default AmountDisplay;
90 changes: 37 additions & 53 deletions src/components/AmountTextInput.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,13 @@

import React, { useState, useRef, useEffect, forwardRef, useImperativeHandle } from 'react';
import { StyleSheet, TextInput } from 'react-native';
import { getAmountParsed, getIntegerAmount } from '../utils';
import { getAmountParsed, getIntegerAmount, computeAmountFontFit, AMOUNT_MAX_LINES } from '../utils';
import { MAX_DECIMAL_PLACES } from '../constants';
import { COLORS } from '../styles/themes';

// Auto-shrink lower bound: never scale the font below this fraction of
// the base size, so very long amounts stay readable even when scaled.
const MIN_FONT_SCALE = 0.5;

// Average glyph width as a fraction of fontSize for the bold sans-serif
// AmountTextInput uses. We don't measure rendered text (an earlier
// implementation that did caused a Folly F14Set assertion crash on
// iOS 26.1 + Fabric due to the extra hidden <Text> re-rendering on
// every keystroke), so we estimate width as
// `length * fontSize * GLYPH_RATIO`. Slightly conservative for digits
// and slightly generous for `,` / `.` — net effect is the font shrinks
// a hair sooner than strictly necessary, which is preferable to letting
// the value clip behind the token selector.
const GLYPH_RATIO = 0.55;
// Rendered line height as a fraction of the font size, reserving room for the
// wrapped lines without clipping the bold glyphs.
const LINE_HEIGHT_RATIO = 1.2;

/**
* Text input component specifically for handling token amounts with BigInt validation.
Expand All @@ -37,7 +27,9 @@ const GLYPH_RATIO = 0.55;
* (no decimals)
* @param {Object} [props.style] - Additional styles for the TextInput
* @param {boolean} [props.autoFocus] - Whether the input should be focused on mount
* @param {number} [props.decimalPlaces] - Number of decimal places to use
* @param {number} [props.decimalPlaces] - Number of decimal places for the token's value scale
* @param {number} [props.fontSize] - Explicit display font size in px (parent-controlled mode)
* @param {boolean} [props.singleLine] - Pin the input to a fixed one-line height (no wrapping)
* @param {React.Ref} ref - Forwarded ref, exposes the focus() method
* @returns {React.ReactElement} A formatted amount input component
*/
Expand All @@ -46,21 +38,8 @@ const AmountTextInput = forwardRef((props, ref) => {
const [text, setText] = useState(props.value || '');
const { decimalPlaces } = props;

// Auto-shrink-to-fit: amounts can grow long enough to overflow the
// input's column (e.g. `957,791,973.79`). We capture the column
// width via the TextInput's `onLayout` and scale `fontSize` down
// when the estimated text width (length × fontSize × GLYPH_RATIO)
// exceeds it. When the user shortens the value, the estimate drops
// and the font scales back up to the base size.
//
// IMPORTANT: `onLayout` measures this TextInput's OWN box, so callers
// MUST give it a parent-determined width — `flex: 1` when it shares a
// row (e.g. with a TokenBox), or `alignSelf: 'stretch'` / an explicit
// width when it sits alone in an `alignItems: 'center'` column. Without
// that the input sizes to its content; since `fontSize` (which this
// logic controls) also drives the content width, the measurement feeds
// back into itself and the font collapses to `minFontSize`, flickering
// on the way down.
// Self-measure: the input's own column width, captured via onLayout, drives the
// font ladder. Unused when the parent passes an explicit fontSize.
const [containerWidth, setContainerWidth] = useState(0);

// Expose the focus method to parent components
Expand Down Expand Up @@ -98,14 +77,18 @@ const AmountTextInput = forwardRef((props, ref) => {
return;
}

let parsedText = newText;
// The numeric keyboard shouldn't emit newlines, but a multiline input can
// receive them via paste; strip so they never reach the parser.
let parsedText = newText.replace(/[\n\r]/g, '');
let bigIntValue;
if (props.allowOnlyInteger) {
// We allow only integers for NFT
parsedText = parsedText.replace(/[^0-9]/g, '');
}

parsedText = getAmountParsed(parsedText, decimalPlaces);
// Accept up to MAX_DECIMAL_PLACES typed decimals regardless of the token's
// precision; the value below is still scaled to the token's decimalPlaces.
parsedText = getAmountParsed(parsedText, MAX_DECIMAL_PLACES);

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.

issue(blocking): typed decimals beyond the token's precision are silently truncated

getAmountParsed(parsedText, MAX_DECIMAL_PLACES) keeps 8 typed decimals, but line 97 scales with the token's decimalPlaces and getIntegerAmount slices the excess away.

On a 2-decimal network, typing 1.12345678 displays that string and sends 112n (1.12); 0.00000001 yields 0n, which isValid accepts, so the field shows a value while Continue stays disabled with no error.

Previously the text was clamped, so what you saw was what you sent. Consider getAmountParsed(parsedText, decimalPlaces ?? MAX_DECIMAL_PLACES), which keeps the undefined-serverInfo fallback without discarding entered precision.


// There is no NaN in BigInt, it either returns a valid bigint or throws
// an error.
Expand Down Expand Up @@ -135,46 +118,47 @@ const AmountTextInput = forwardRef((props, ref) => {
placeholder = `0.${zeros}`;
}

const { style: customStyle, textAlign, ...restProps } = props;
const { style: customStyle, textAlign, fontSize, singleLine, ...restProps } = props;

// Resolve the base (unscaled) font size from the merged style chain
// so callers that override fontSize via `customStyle` still get
// correct scaling math.
const flatStyle = StyleSheet.flatten([style.input, customStyle]) || {};
const baseFontSize = flatStyle.fontSize ?? 32;
const minFontSize = Math.max(14, Math.floor(baseFontSize * MIN_FONT_SCALE));
const isControlledSize = fontSize != null;
const displayed = text || placeholder;
const estimatedWidth = displayed.length * baseFontSize * GLYPH_RATIO;
const scaledFontSize = (containerWidth > 0 && estimatedWidth > containerWidth)
? Math.max(
minFontSize,
Math.floor(baseFontSize * (containerWidth / estimatedWidth)),
)
: baseFontSize;
const resolvedFontSize = isControlledSize
? fontSize
: computeAmountFontFit(displayed.length, containerWidth).fontSize;
const lineHeight = Math.round(resolvedFontSize * LINE_HEIGHT_RATIO);
const heightStyle = singleLine
? { height: lineHeight }
: { maxHeight: lineHeight * AMOUNT_MAX_LINES };

return (
<TextInput
ref={inputRef}
style={[style.input, customStyle, { fontSize: scaledFontSize }]}
style={[
style.input,
customStyle,
{ fontSize: resolvedFontSize, lineHeight },
heightStyle,
]}
onChangeText={onChangeText}
value={text}
multiline
scrollEnabled={false}
textAlign={textAlign || 'center'}
textAlignVertical='bottom'
textAlignVertical='center'
keyboardAppearance='dark'
keyboardType='numeric'
placeholder={placeholder}
placeholderTextColor={COLORS.midContrastDetail}
onLayout={(e) => setContainerWidth(e.nativeEvent.layout.width)}
onLayout={isControlledSize
? undefined
: (e) => setContainerWidth(e.nativeEvent.layout.width)}
{...restProps}
/>
);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const style = StyleSheet.create({
input: {
height: 38,
lineHeight: 38,
fontSize: 32,
fontWeight: 'bold',
paddingVertical: 0,
color: COLORS.textColor,
Expand Down
2 changes: 1 addition & 1 deletion src/components/TokenBox.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { COLORS } from '../styles/themes';

const TokenBox = (props) => (
<TouchableWithoutFeedback onPress={props.onPress}>
<View style={styles.wrapper}>
<View style={[styles.wrapper, props.style]}>
<Text style={styles.label}>{props.label}</Text>
<FontAwesomeIcon
icon={faSortDown}
Expand Down
11 changes: 3 additions & 8 deletions src/components/TxDetailsModal.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { t } from 'ttag';

import { TokenVersion } from '@hathor/wallet-lib';
import { getShortContent, getShortHash, getTokenLabel, renderValue } from '../utils';
import AmountDisplay from './AmountDisplay';
import { ListItem } from './HathorList';
import SlideIndicatorBar from './SlideIndicatorBar';
import CopyClipboard from './CopyClipboard';
Expand Down Expand Up @@ -153,7 +154,6 @@ class BalanceView extends Component {
paddingRight: 54,
},
balance: {
fontSize: 32,
fontWeight: 'bold',
},
text1: {
Expand All @@ -169,14 +169,9 @@ class BalanceView extends Component {
const balanceStr = renderValue(tx.balance, isNFT, decimalPlaces, amountFormat);
return (
<View style={this.style.view}>
<Text
style={this.style.balance}
adjustsFontSizeToFit
minimumFontScale={0.5}
numberOfLines={1}
>
<AmountDisplay style={this.style.balance}>
{`${balanceStr} ${this.props.token.symbol}`}
</Text>
</AmountDisplay>
<Text style={this.style.text1}>{t`Amount`}</Text>
</View>
);
Expand Down
4 changes: 4 additions & 0 deletions src/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,10 @@ export const AMOUNT_FORMAT_DEFAULT = AMOUNT_FORMAT.EXPANDED;
// 'wallet:' prefix: STORE.clearItems(true) sweeps it on resetWallet, so it resets to Expanded.
export const AMOUNT_FORMAT_KEY = 'wallet:amount_format';

// Max decimal places the amount input accepts, independent of the token's own
// precision — the value is still scaled to the token's decimal_places downstream.
export const MAX_DECIMAL_PLACES = 8;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* this is the message key for localization of new transaction when show amount is enabled
*/
Expand Down
1 change: 1 addition & 0 deletions src/screens/CreateTokenAmount.js
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ const CreateTokenAmount = () => {
decimalPlaces={decimalPlaces}
onAmountUpdate={onAmountChange}
value={amountText}
style={{ alignSelf: 'stretch' }}
/>
{error && (
<Text style={{ color: COLORS.errorTextColor, marginTop: 8, textAlign: 'center' }}>
Expand Down
30 changes: 7 additions & 23 deletions src/screens/MainScreen.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import IconTabBar from '../icon-font';
import HathorHeader from '../components/HathorHeader';
import SimpleButton from '../components/SimpleButton';
import TxDetailsModal from '../components/TxDetailsModal';
import AmountDisplay from '../components/AmountDisplay';
import OfflineBar from '../components/OfflineBar';
import { HathorList } from '../components/HathorList';
import {
Expand Down Expand Up @@ -531,11 +532,9 @@ class BalanceView extends React.Component {
},
balanceLocked: {
marginTop: 24,
fontSize: 18,
fontWeight: 'bold',
},
balanceAvailable: {
fontSize: 32,
fontWeight: 'bold',
},
text1: {
Expand Down Expand Up @@ -571,23 +570,13 @@ class BalanceView extends React.Component {
const { style } = this;
return (
<View style={style.center}>
<Text
style={style.balanceAvailable}
adjustsFontSizeToFit
minimumFontScale={0.5}
numberOfLines={1}
>
<AmountDisplay style={style.balanceAvailable}>
{`${availableStr} ${token.symbol}`}
</Text>
</AmountDisplay>
<Text style={style.text1}>{t`Available Balance`}</Text>
<Text
style={style.balanceLocked}
adjustsFontSizeToFit
minimumFontScale={0.5}
numberOfLines={1}
>
<AmountDisplay style={style.balanceLocked} baseFontSize={18} minFontSize={12}>
{`${lockedStr} ${token.symbol}`}
</Text>
</AmountDisplay>
<Text style={style.text1}>{t`Locked`}</Text>
<Image style={style.expandButton} source={chevronUp} width={12} height={7} />
</View>
Expand All @@ -605,14 +594,9 @@ class BalanceView extends React.Component {
const { style } = this;
return (
<View style={style.center}>
<Text
style={style.balanceAvailable}
adjustsFontSizeToFit
minimumFontScale={0.5}
numberOfLines={1}
>
<AmountDisplay style={style.balanceAvailable}>
{`${availableStr} ${token.symbol}`}
</Text>
</AmountDisplay>
<Text style={style.text1}>{t`Available Balance`}</Text>
<Image style={style.expandButton} source={chevronDown} width={12} height={7} />
</View>
Expand Down
Loading
Loading