* feat: redesign transaction detail screen with unified layout and Lottie pending animation * ADD: decode OP_RETURN payload as UTF-8 text in transaction detail Co-authored-by: Cursor <cursoragent@cursor.com> * REF: transaction detail redesign (themes, pending icon, loc) Co-authored-by: Cursor <cursoragent@cursor.com> * REF: remove deprecated TransactionDetails, TransactionStatus and getTransactionStatusOptions Co-authored-by: Cursor <cursoragent@cursor.com> * FIX: resolve lint errors (unused vars, styles, loc keys, no-bitwise, inline styles) Co-authored-by: Cursor <cursoragent@cursor.com> * FIX: remove redundant !tx check in transaction detail guard Co-authored-by: Cursor <cursoragent@cursor.com> * FIX: show transaction not available when tx not found after load Co-authored-by: Cursor <cursoragent@cursor.com> * FIX: remove unused transaction prop type from TransactionDetail Co-authored-by: Cursor <cursoragent@cursor.com> * TST: update UTXO note E2E to use new transaction detail note prompt UI Co-authored-by: Cursor <cursoragent@cursor.com> * simplify changes on the PR for review * remove unused loc * remove unchanged colors * better offline support for tx details * remove unused key loc * fix code review issues * fix balance * fix tests * REF: address PR #8289 review feedback * redesigned wallets details * fix lint * fix lint * fix bip84 test * fix test * fix tests * fix tests * fix: truncation and sendTo logic display * fix: new arch fixes * fix: lint * fix: crash on status update * fix: lint and tests * fix: tests * fix: tests * fix: tests * fix: tests * fix: tests * Potential fix for pull request finding 'Identical operands' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> * fix: tests * fix: tests * fix: tests * fix: tests * fix: tests * fix style * fix merge master * Merge branch 'wallet-details' of https://github.com/BlueWallet/BlueWallet into wallet-details * fix loc * fix loc * fix style * improve coin control from wallet details * fix: e2e * fix: WalletDetails * fix: flat * fix: e2e * fix: e2e * Potential fix for pull request finding 'Unused variable, import, function or class' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> * fix: remove notifications dialogs * fix: second button title --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Ivan Vershigora <ivan.vershigora@gmail.com> Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> Co-authored-by: Overtorment <overtorment@gmail.com>
457 lines
15 KiB
TypeScript
457 lines
15 KiB
TypeScript
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import { RouteProp, useFocusEffect, useRoute } from '@react-navigation/native';
|
|
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
|
import Avatar from '../../components/Avatar';
|
|
import Badge from '../../components/Badge';
|
|
import Icon from '../../components/Icon';
|
|
import { Animated, ActivityIndicator, Keyboard, PixelRatio, Pressable, StyleSheet, Text, useWindowDimensions, View } from 'react-native';
|
|
import debounce from '../../blue_modules/debounce';
|
|
import { TWallet, Utxo } from '../../class/wallets/types';
|
|
import { FButton, FContainer } from '../../components/FloatButtons';
|
|
import HeaderMenuButton from '../../components/HeaderMenuButton';
|
|
import SafeArea from '../../components/SafeArea';
|
|
import SafeAreaScrollView from '../../components/SafeAreaScrollView';
|
|
import { useTheme } from '../../components/themes';
|
|
import { Action } from '../../components/types';
|
|
import { useStorage } from '../../hooks/context/useStorage';
|
|
import { useExtendedNavigation } from '../../hooks/useExtendedNavigation';
|
|
import loc, { formatBalance } from '../../loc';
|
|
import { BitcoinUnit } from '../../models/bitcoinUnits';
|
|
import { goFromCoinControlToSendDetails } from '../../navigation/goFromCoinControlToSendDetails';
|
|
import { SendDetailsStackParamList } from '../../navigation/SendDetailsStackParamList';
|
|
import { CommonToolTipActions } from '../../typings/CommonToolTipActions';
|
|
|
|
type NavigationProps = NativeStackNavigationProp<SendDetailsStackParamList, 'CoinControl'>;
|
|
type RouteProps = RouteProp<SendDetailsStackParamList, 'CoinControl'>;
|
|
|
|
const FrozenBadge: React.FC = () => {
|
|
const { colors } = useTheme();
|
|
return (
|
|
<Badge
|
|
testID="FrozenBadge"
|
|
value={loc.cc.freeze}
|
|
badgeStyle={[styles.badge, { backgroundColor: colors.redBG }]}
|
|
textStyle={[styles.badgeText, { color: colors.redText }]}
|
|
/>
|
|
);
|
|
};
|
|
|
|
const ChangeBadge: React.FC = () => {
|
|
const { colors } = useTheme();
|
|
return (
|
|
<Badge
|
|
testID="ChangeBadge"
|
|
value={loc.cc.change}
|
|
badgeStyle={[styles.badge, { backgroundColor: colors.buttonDisabledBackgroundColor }]}
|
|
textStyle={[styles.badgeText, { color: colors.alternativeTextColor }]}
|
|
/>
|
|
);
|
|
};
|
|
|
|
const AnimatedTip: React.FC<{ text: string }> = ({ text }) => {
|
|
const { colors } = useTheme();
|
|
const heightAnim = useRef(new Animated.Value(0)).current;
|
|
const currentHeight = useRef(0);
|
|
const [measured, setMeasured] = useState(false);
|
|
|
|
const onContentLayout = useCallback(
|
|
(e: { nativeEvent: { layout: { height: number } } }) => {
|
|
const newHeight = e.nativeEvent.layout.height;
|
|
if (!measured) {
|
|
currentHeight.current = newHeight;
|
|
heightAnim.setValue(newHeight);
|
|
setMeasured(true);
|
|
return;
|
|
}
|
|
if (Math.abs(newHeight - currentHeight.current) < 1) return;
|
|
currentHeight.current = newHeight;
|
|
Animated.timing(heightAnim, { toValue: newHeight, duration: 250, useNativeDriver: false }).start();
|
|
},
|
|
[heightAnim, measured],
|
|
);
|
|
|
|
return (
|
|
<Animated.View style={[styles.tipOuter, measured && styles.tipOverflow, measured && { height: heightAnim }]}>
|
|
<View
|
|
onLayout={onContentLayout}
|
|
style={[styles.tipContainer, { backgroundColor: colors.ballOutgoingExpired }, measured && styles.tipAbsolute]}
|
|
>
|
|
<Text style={{ color: colors.foregroundColor }}>{text}</Text>
|
|
</View>
|
|
</Animated.View>
|
|
);
|
|
};
|
|
|
|
type TOutputListProps = {
|
|
item: Utxo;
|
|
balanceUnit: string;
|
|
oMemo?: string;
|
|
frozen: boolean;
|
|
change: boolean;
|
|
onOpen: () => void;
|
|
selected: boolean;
|
|
selectionStarted: boolean;
|
|
onSelect: () => void;
|
|
onDeSelect: () => void;
|
|
};
|
|
|
|
const OutputList: React.FC<TOutputListProps> = ({
|
|
item: { address, txid, value },
|
|
balanceUnit = BitcoinUnit.BTC,
|
|
oMemo,
|
|
frozen,
|
|
change,
|
|
onOpen,
|
|
selected,
|
|
selectionStarted,
|
|
onSelect,
|
|
onDeSelect,
|
|
}: TOutputListProps) => {
|
|
const { colors } = useTheme();
|
|
const { txMetadata } = useStorage();
|
|
const memo = oMemo || txMetadata[txid]?.memo || '';
|
|
const color = `#${txid.substring(0, 6)}`;
|
|
const amount = formatBalance(value, balanceUnit, true);
|
|
|
|
let onPress = onOpen;
|
|
if (selectionStarted) {
|
|
onPress = selected ? onDeSelect : onSelect;
|
|
}
|
|
|
|
const oStyles = StyleSheet.create({
|
|
container: { borderBottomColor: colors.lightBorder, backgroundColor: 'transparent' },
|
|
avatar: { borderColor: 'white', borderWidth: 1, backgroundColor: color },
|
|
avatarSelected: { borderColor: 'white', borderWidth: 1, backgroundColor: colors.successColor },
|
|
amount: { fontWeight: 'bold', color: colors.foregroundColor },
|
|
memo: { fontSize: 13, marginTop: 3, color: colors.alternativeTextColor },
|
|
containerSelected: { backgroundColor: colors.ballOutgoingExpired, borderBottomColor: 'rgba(0, 0, 0, 0)' },
|
|
});
|
|
|
|
return (
|
|
<Pressable onPress={onPress} style={[styles.listRow, selected ? oStyles.containerSelected : oStyles.container]}>
|
|
<Avatar
|
|
rounded
|
|
size={40}
|
|
containerStyle={selected ? oStyles.avatarSelected : oStyles.avatar}
|
|
onPress={selected ? onDeSelect : onSelect}
|
|
icon={selected ? { name: 'check', type: 'font-awesome-6', color: 'white', size: 18 } : undefined}
|
|
/>
|
|
<View style={styles.itemContent}>
|
|
<Text style={oStyles.amount}>{amount}</Text>
|
|
<Text testID="OutputMemoLabel" style={oStyles.memo} numberOfLines={1} ellipsizeMode="middle">
|
|
{memo || address}
|
|
</Text>
|
|
</View>
|
|
<View style={styles.badges}>
|
|
{frozen && <FrozenBadge />}
|
|
{change && <ChangeBadge />}
|
|
</View>
|
|
</Pressable>
|
|
);
|
|
};
|
|
|
|
enum ESortDirections {
|
|
asc = 'asc',
|
|
desc = 'desc',
|
|
}
|
|
|
|
enum ESortTypes {
|
|
height = 'height',
|
|
label = 'label',
|
|
value = 'value',
|
|
frozen = 'frozen',
|
|
}
|
|
|
|
const CoinControl: React.FC = () => {
|
|
const { colors } = useTheme();
|
|
const navigation = useExtendedNavigation<NavigationProps>();
|
|
const { width } = useWindowDimensions();
|
|
const { walletID } = useRoute<RouteProps>().params;
|
|
const { wallets, saveToDisk, sleep } = useStorage();
|
|
const [sortDirection, setSortDirection] = useState<ESortDirections>(ESortDirections.asc);
|
|
const [sortType, setSortType] = useState<ESortTypes>(ESortTypes.height);
|
|
const wallet = useMemo(() => wallets.find(w => w.getID() === walletID) as TWallet, [walletID, wallets]);
|
|
const [frozen, setFrozen] = useState<string[]>(
|
|
wallet
|
|
.getUtxo(true)
|
|
.filter(out => wallet.getUTXOMetadata(out.txid, out.vout).frozen)
|
|
.map(({ txid, vout }) => `${txid}:${vout}`),
|
|
);
|
|
const utxos: Utxo[] = useMemo(() => {
|
|
const res = wallet.getUtxo(true).sort((a, b) => {
|
|
switch (sortType) {
|
|
case ESortTypes.height:
|
|
return a.height - b.height || a.txid.localeCompare(b.txid) || a.vout - b.vout;
|
|
case ESortTypes.value:
|
|
return a.value - b.value || a.txid.localeCompare(b.txid) || a.vout - b.vout;
|
|
case ESortTypes.label: {
|
|
const aMemo = wallet.getUTXOMetadata(a.txid, a.vout).memo || '';
|
|
const bMemo = wallet.getUTXOMetadata(b.txid, b.vout).memo || '';
|
|
return aMemo.localeCompare(bMemo) || a.txid.localeCompare(b.txid) || a.vout - b.vout;
|
|
}
|
|
case ESortTypes.frozen: {
|
|
const aF = frozen.includes(`${a.txid}:${a.vout}`);
|
|
const bF = frozen.includes(`${b.txid}:${b.vout}`);
|
|
return aF !== bF ? (aF ? -1 : 1) : a.txid.localeCompare(b.txid) || a.vout - b.vout;
|
|
}
|
|
default:
|
|
return 0;
|
|
}
|
|
});
|
|
// invert if descending
|
|
return sortDirection === ESortDirections.desc ? res.reverse() : res;
|
|
}, [sortDirection, sortType, wallet, frozen]);
|
|
const [loading, setLoading] = useState<boolean>(true);
|
|
const [selected, setSelected] = useState<string[]>([]);
|
|
|
|
// save frozen status. Because effect called on each event, debounce it.
|
|
const debouncedSaveFronen = useRef(
|
|
debounce(async frzn => {
|
|
utxos.forEach(({ txid, vout }) => {
|
|
wallet.setUTXOMetadata(txid, vout, { frozen: frzn.includes(`${txid}:${vout}`) });
|
|
});
|
|
await saveToDisk();
|
|
}, 500),
|
|
);
|
|
useEffect(() => {
|
|
debouncedSaveFronen.current(frozen);
|
|
}, [frozen]);
|
|
|
|
useEffect(() => {
|
|
(async () => {
|
|
try {
|
|
await Promise.race([wallet.fetchUtxo(), sleep(10000)]);
|
|
} catch (e) {
|
|
console.log('coincontrol wallet.fetchUtxo() failed'); // either sleep expired or fetchUtxo threw an exception
|
|
}
|
|
const freshUtxo = wallet.getUtxo(true);
|
|
setFrozen(freshUtxo.filter(out => wallet.getUTXOMetadata(out.txid, out.vout).frozen).map(({ txid, vout }) => `${txid}:${vout}`));
|
|
setLoading(false);
|
|
})();
|
|
}, [wallet, setLoading, sleep]);
|
|
|
|
useFocusEffect(
|
|
useCallback(() => {
|
|
if (!wallet) return;
|
|
const refreshedFrozen = wallet
|
|
.getUtxo(true)
|
|
.filter(out => wallet.getUTXOMetadata(out.txid, out.vout).frozen)
|
|
.map(({ txid, vout }) => `${txid}:${vout}`);
|
|
|
|
setFrozen(refreshedFrozen);
|
|
// Clear any stale selection that might reference outdated frozen state.
|
|
setSelected([]);
|
|
}, [wallet]),
|
|
);
|
|
|
|
const tipText = useMemo(() => {
|
|
if (utxos.length === 0) return '';
|
|
if (selected.length === 0) return loc.cc.tip;
|
|
const summ = selected.reduce((prev, curr) => {
|
|
return prev + (utxos.find(({ txid, vout }) => `${txid}:${vout}` === curr) as Utxo).value;
|
|
}, 0);
|
|
const value = formatBalance(summ, wallet.getPreferredBalanceUnit(), true);
|
|
return loc.formatString(loc.cc.selected_summ, { value });
|
|
}, [selected, utxos, wallet]);
|
|
|
|
const tipCoins = () => {
|
|
if (utxos.length === 0) return null;
|
|
return <AnimatedTip text={tipText} />;
|
|
};
|
|
|
|
const handleChoose = (item: Utxo) => navigation.navigate('CoinControlOutput', { walletID, utxo: item });
|
|
|
|
const handleUseCoin = (u: Utxo[]) => {
|
|
goFromCoinControlToSendDetails(navigation, walletID, u);
|
|
};
|
|
|
|
const handleMassFreeze = () => {
|
|
if (allFrozen) {
|
|
setFrozen(f => f.filter(i => !selected.includes(i))); // unfreeze
|
|
} else {
|
|
setFrozen(f => [...new Set([...f, ...selected])]); // freeze
|
|
}
|
|
};
|
|
|
|
const handleMassUse = () => {
|
|
const fUtxo = utxos.filter(({ txid, vout }) => selected.includes(`${txid}:${vout}`));
|
|
handleUseCoin(fUtxo);
|
|
};
|
|
|
|
// check if any outputs are selected
|
|
const selectionStarted = selected.length > 0;
|
|
// check if all selected items are frozen
|
|
const allFrozen = selectionStarted && selected.reduce((prev, curr) => (prev ? frozen.includes(curr) : false), true);
|
|
const buttonFontSize = PixelRatio.roundToNearestPixel(width / 26) > 22 ? 22 : PixelRatio.roundToNearestPixel(width / 26);
|
|
|
|
const renderItem = (item: Utxo) => {
|
|
const key = `${item.txid}:${item.vout}`;
|
|
const { memo } = wallet.getUTXOMetadata(item.txid, item.vout);
|
|
const isChange = wallet.addressIsChange(item.address);
|
|
const oFrozen = frozen.includes(key);
|
|
return (
|
|
<OutputList
|
|
key={key}
|
|
balanceUnit={wallet.getPreferredBalanceUnit()}
|
|
item={item}
|
|
oMemo={memo}
|
|
frozen={oFrozen}
|
|
change={isChange}
|
|
onOpen={() => handleChoose(item)}
|
|
selected={selected.includes(key)}
|
|
selectionStarted={selectionStarted}
|
|
onSelect={() => {
|
|
setSelected(s => [...s, key]);
|
|
}}
|
|
onDeSelect={() => {
|
|
setSelected(s => s.filter(i => i !== key));
|
|
}}
|
|
/>
|
|
);
|
|
};
|
|
|
|
const toolTipActions = useMemo((): Action[] | Action[][] => {
|
|
return [
|
|
[sortDirection === ESortDirections.asc ? CommonToolTipActions.SortASC : CommonToolTipActions.SortDESC],
|
|
[
|
|
{ ...CommonToolTipActions.SortHeight, menuState: sortType === ESortTypes.height },
|
|
{ ...CommonToolTipActions.SortValue, menuState: sortType === ESortTypes.value },
|
|
{ ...CommonToolTipActions.SortLabel, menuState: sortType === ESortTypes.label },
|
|
{ ...CommonToolTipActions.SortStatus, menuState: sortType === ESortTypes.frozen },
|
|
],
|
|
];
|
|
}, [sortDirection, sortType]);
|
|
|
|
const toolTipOnPressMenuItem = useCallback((menuItem: string) => {
|
|
Keyboard.dismiss();
|
|
if (menuItem === CommonToolTipActions.SortASC.id) {
|
|
setSortDirection(ESortDirections.desc);
|
|
} else if (menuItem === CommonToolTipActions.SortDESC.id) {
|
|
setSortDirection(ESortDirections.asc);
|
|
} else if (menuItem === CommonToolTipActions.SortHeight.id) {
|
|
setSortType(ESortTypes.height);
|
|
} else if (menuItem === CommonToolTipActions.SortValue.id) {
|
|
setSortType(ESortTypes.value);
|
|
} else if (menuItem === CommonToolTipActions.SortLabel.id) {
|
|
setSortType(ESortTypes.label);
|
|
} else if (menuItem === CommonToolTipActions.SortStatus.id) {
|
|
setSortType(ESortTypes.frozen);
|
|
}
|
|
}, []);
|
|
|
|
const HeaderRight = useMemo(
|
|
() => <HeaderMenuButton onPressMenuItem={toolTipOnPressMenuItem} actions={toolTipActions} title={loc.cc.sort_by} />,
|
|
[toolTipOnPressMenuItem, toolTipActions],
|
|
);
|
|
|
|
// Adding the ToolTipMenu to the header
|
|
useEffect(() => {
|
|
navigation.setOptions({
|
|
headerRight: () => (utxos.length > 0 ? HeaderRight : null),
|
|
});
|
|
}, [HeaderRight, navigation, utxos.length]);
|
|
|
|
if (loading) {
|
|
return (
|
|
<SafeArea style={[styles.center, { backgroundColor: colors.elevated }]}>
|
|
<ActivityIndicator testID="Loading" />
|
|
</SafeArea>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<View style={[styles.root, { backgroundColor: colors.elevated }]}>
|
|
{utxos.length === 0 && (
|
|
<View style={styles.empty}>
|
|
<Text style={{ color: colors.foregroundColor }}>{loc.cc.empty}</Text>
|
|
</View>
|
|
)}
|
|
<SafeAreaScrollView contentInsetAdjustmentBehavior="automatic" contentContainerStyle={styles.listContent}>
|
|
{tipCoins()}
|
|
{utxos.map(renderItem)}
|
|
</SafeAreaScrollView>
|
|
|
|
{selectionStarted && (
|
|
<FContainer>
|
|
<FButton
|
|
onPress={handleMassFreeze}
|
|
text={allFrozen ? loc.cc.freezeLabel_un : loc.cc.freezeLabel}
|
|
icon={<Icon name="snowflake" size={buttonFontSize} type="font-awesome-6" color={colors.buttonAlternativeTextColor} />}
|
|
/>
|
|
<FButton
|
|
onPress={handleMassUse}
|
|
text={selected.length > 1 ? loc.cc.use_coins : loc.cc.use_coin}
|
|
icon={
|
|
<View style={styles.sendIcon}>
|
|
<Icon name="arrow-down" size={buttonFontSize} type="font-awesome" color={colors.buttonAlternativeTextColor} />
|
|
</View>
|
|
}
|
|
/>
|
|
</FContainer>
|
|
)}
|
|
</View>
|
|
);
|
|
};
|
|
|
|
const styles = StyleSheet.create({
|
|
root: {
|
|
flex: 1,
|
|
},
|
|
center: {
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
},
|
|
empty: {
|
|
flex: 1,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
padding: 24,
|
|
},
|
|
sendIcon: {
|
|
transform: [{ rotate: '225deg' }],
|
|
},
|
|
badges: {
|
|
flexDirection: 'row',
|
|
},
|
|
listRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingHorizontal: 16,
|
|
paddingVertical: 12,
|
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
|
},
|
|
itemContent: {
|
|
flex: 1,
|
|
marginLeft: 12,
|
|
},
|
|
listContent: {
|
|
paddingBottom: 70,
|
|
},
|
|
badge: {
|
|
borderWidth: 0,
|
|
marginLeft: 4,
|
|
},
|
|
badgeText: {
|
|
marginTop: -1,
|
|
},
|
|
tipOuter: {
|
|
marginVertical: 24,
|
|
marginHorizontal: 16,
|
|
},
|
|
tipOverflow: {
|
|
overflow: 'hidden',
|
|
},
|
|
tipContainer: {
|
|
borderRadius: 12,
|
|
padding: 16,
|
|
},
|
|
tipAbsolute: {
|
|
position: 'absolute',
|
|
left: 0,
|
|
right: 0,
|
|
top: 0,
|
|
},
|
|
});
|
|
|
|
export default CoinControl;
|