Compare commits

...

11 Commits

Author SHA1 Message Date
Nuno
8b56b40d6c
Merge branch 'master' into feat-confblocks 2026-06-19 09:24:52 +01:00
ncoelho
cdec49160f add touch 2026-06-18 16:44:22 +02:00
ncoelho
e8f1fc6786 Merge branch 'feat-confblocks' of https://github.com/BlueWallet/BlueWallet into feat-confblocks 2026-06-18 16:32:05 +02:00
ncoelho
4c3e8764a0 add touch 2026-06-18 16:31:40 +02:00
Nuno
9aca00ae56
Merge branch 'master' into feat-confblocks 2026-06-18 15:25:53 +01:00
ncoelho
593e410a20 fix tests 2026-06-18 12:07:26 +02:00
ncoelho
f323f5132b add haptics 2026-06-18 12:04:45 +02:00
ncoelho
9db0b759fd Merge remote-tracking branch 'origin/master' into feat-confblocks 2026-06-18 11:39:51 +02:00
ncoelho
b2e8c01011 new version 2026-06-12 13:02:51 +01:00
ncoelho
dd71e0ff60 style fix 2026-06-12 11:29:58 +01:00
ncoelho
b943d9f641 feat: conf blocks on sent/receive 2026-06-12 11:29:58 +01:00
7 changed files with 588 additions and 33 deletions

View File

@ -1494,6 +1494,84 @@ export function getServerBanner(): Promise<string> {
return mainClient.request('server.banner', []);
}
export function getTxBlockHeight(txHash: string): number | undefined {
return txhashHeightCache[txHash];
}
export async function getCurrentBlockTip(): Promise<number> {
if (!mainClient) throw new Error('Electrum client is not connected');
const header = await mainClient.blockchainHeaders_subscribe();
if (header && header.height) {
latestBlock = { height: header.height, time: Math.floor(+new Date() / 1000) };
return header.height;
}
return estimateCurrentBlockheight();
}
/**
* Returns the confirmed block height for a given txHash.
* 1. Tries the in-memory txhashHeightCache (populated from address history).
* 2. Falls back to a DIRECT server call (bypassing Realm cache) to get fresh confirmations.
* Uses standard Bitcoin convention: height = tip - confirmations + 1.
*/
export async function getConfirmedBlockHeight(txHash: string): Promise<number | null> {
const cached = txhashHeightCache[txHash];
if (cached && cached > 0) return cached;
if (!mainClient) return null;
try {
const [tip, verboseTx] = await Promise.all([
getCurrentBlockTip(),
mainClient.blockchainTransaction_get(txHash, true),
]);
if (typeof verboseTx === 'string') {
// Server didn't support verbose — decode locally.
// Without txhashHeightCache entry we can't determine height.
return null;
}
const confirmations = Number(verboseTx?.confirmations);
if (!confirmations || confirmations <= 0) return null;
const height = tip - confirmations + 1;
txhashHeightCache[txHash] = height;
return height;
} catch (e) {
console.warn('getConfirmedBlockHeight: failed', e);
return null;
}
}
/**
* Fetches actual block header timestamps from the Electrum server for the given heights.
* Parses the 80-byte hex-encoded header to extract the 4-byte LE timestamp at byte offset 68.
*/
export async function getBlockTimestamps(heights: number[]): Promise<Record<number, number>> {
if (!mainClient) throw new Error('Electrum client is not connected');
const result: Record<number, number> = {};
const promises = heights.map(async height => {
try {
const headerHex: string = await mainClient.blockchainBlock_header(height);
// timestamp is at bytes 6871 of the 80-byte header (hex chars 136143), little-endian uint32
const tsHex = headerHex.slice(136, 144);
/* eslint-disable no-bitwise */
const timestamp =
parseInt(tsHex.slice(0, 2), 16) |
(parseInt(tsHex.slice(2, 4), 16) << 8) |
(parseInt(tsHex.slice(4, 6), 16) << 16) |
((parseInt(tsHex.slice(6, 8), 16) << 24) >>> 0);
/* eslint-enable no-bitwise */
result[height] = timestamp;
} catch (e) {
console.warn(`Failed to fetch block header for height ${height}:`, e);
}
});
await Promise.all(promises);
return result;
}
const splitIntoChunks = function (arr: any[], chunkSize: number) {
const groups = [];
let i;

View File

@ -0,0 +1,397 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
ActivityIndicator,
FlatList,
LayoutChangeEvent,
ListRenderItemInfo,
StyleSheet,
Text,
TextStyle,
TouchableOpacity,
useColorScheme,
useWindowDimensions,
View,
} from 'react-native';
import Animated, { Easing, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
import LottieView from 'lottie-react-native';
import LinearGradient from 'react-native-linear-gradient';
import dayjs from 'dayjs';
import { useTheme } from './themes';
import * as BlueElectrum from '../blue_modules/BlueElectrum';
import loc, { formatBalanceWithoutSuffix } from '../loc';
import { BitcoinUnit } from '../models/bitcoinUnits';
const BLOCK_COUNT = 5;
const COLLAPSED_HEIGHT = 0;
const BLOCKS_HEIGHT = 130;
const ANIMATION_DURATION = 280;
const ANIMATION_EASING = Easing.out(Easing.cubic);
const BLOCK_CARD_WIDTH = 120;
const BLOCK_CARD_GAP = 8;
const ITEM_LENGTH = BLOCK_CARD_WIDTH + BLOCK_CARD_GAP;
const HORIZONTAL_PADDING = 8;
const STATE_CARD_MARGIN_H = 24;
const txblockAnimation = require('../img/txblock.json');
const BLOCK_GRADIENT_EDGE_OPACITY = 0.45;
const BLOCK_GRADIENT_TOP_START = { x: 0.5, y: 0 };
const BLOCK_GRADIENT_TOP_END = { x: 0.5, y: 1 };
const BLOCK_GRADIENT_BOTTOM_START = { x: 0.5, y: 1 };
const BLOCK_GRADIENT_BOTTOM_END = { x: 0.5, y: 0 };
interface BlocksAccordionProps {
txHash: string;
isSent: boolean;
isExpanded: boolean;
confirmations: number;
vsize?: number | null;
feeSats?: number | null;
feeRate?: number | null;
onPress?: () => void;
}
interface BlockData {
height: number;
timestamp?: number;
}
const getItemLayout = (_: unknown, index: number) => ({
length: ITEM_LENGTH,
offset: HORIZONTAL_PADDING + index * ITEM_LENGTH,
index,
});
const renderBoldFormattedParts = (template: string, values: Record<string, string>, boldStyle: TextStyle): React.ReactNode[] => {
const regex = /\{(\w+)\}/g;
const parts: React.ReactNode[] = [];
let lastIndex = 0;
let match;
let index = 0;
while ((match = regex.exec(template)) !== null) {
if (match.index > lastIndex) {
parts.push(template.substring(lastIndex, match.index));
}
const value = values[match[1]];
if (value !== undefined) {
parts.push(
<Text key={`bold-${index++}`} style={boldStyle}>
{value}
</Text>,
);
}
lastIndex = regex.lastIndex;
}
if (lastIndex < template.length) {
parts.push(template.substring(lastIndex));
}
return parts;
};
const BlocksAccordion: React.FC<BlocksAccordionProps> = ({ txHash, isSent, isExpanded, confirmations, vsize, feeSats, feeRate, onPress }) => {
const { colors } = useTheme();
const colorScheme = useColorScheme();
const { width: windowWidth } = useWindowDimensions();
const [blocks, setBlocks] = useState<BlockData[]>([]);
const [confirmedHeight, setConfirmedHeight] = useState<number | null>(null);
const [loading, setLoading] = useState(false);
const [measuredHeight, setMeasuredHeight] = useState(0);
const animatedHeight = useSharedValue(COLLAPSED_HEIGHT);
const fetchStartedRef = useRef(false);
const accentColor = isSent ? colors.transactionSentColor : colors.transactionReceivedColor;
const borderAccent = isSent ? colors.outgoingForegroundColor : colors.incomingForegroundColor;
const blockCardBg = isSent ? 'rgba(208, 2, 27, 0.16)' : 'rgba(30, 138, 106, 0.16)';
const lottieColorFilters = useMemo(() => [{ keypath: '**', color: borderAccent }], [borderAccent]);
const blockGradientColors = useMemo(
() =>
colorScheme === 'dark'
? [`rgba(0, 0, 0, ${BLOCK_GRADIENT_EDGE_OPACITY})`, 'rgba(0, 0, 0, 0)']
: [`rgba(255, 255, 255, ${BLOCK_GRADIENT_EDGE_OPACITY})`, 'rgba(255, 255, 255, 0)'],
[colorScheme],
);
const stylesHook = StyleSheet.create({
blockCardBase: { backgroundColor: blockCardBg },
confirmedBlockCard: { borderColor: borderAccent, borderWidth: 2, backgroundColor: 'transparent' },
summaryText: { color: accentColor },
summaryBold: { color: accentColor, fontWeight: '700' },
});
const fetchBlockData = useCallback(async () => {
if (fetchStartedRef.current) return;
fetchStartedRef.current = true;
setLoading(true);
try {
const [txHeight, currentTip] = await Promise.all([BlueElectrum.getConfirmedBlockHeight(txHash), BlueElectrum.getCurrentBlockTip()]);
if (!txHeight || txHeight <= 0) {
setLoading(false);
return;
}
setConfirmedHeight(txHeight);
const distance = currentTip - txHeight;
let startHeight = txHeight - 2;
if (distance < 2) {
startHeight = currentTip - (BLOCK_COUNT - 1);
}
const heights = Array.from({ length: BLOCK_COUNT }, (_, i) => startHeight + i);
const timestamps = await BlueElectrum.getBlockTimestamps(heights);
setBlocks(heights.map(h => ({ height: h, timestamp: timestamps[h] })));
} catch (e) {
console.warn('BlocksAccordion: failed to fetch block data', e);
} finally {
setLoading(false);
}
}, [txHash]);
useEffect(() => {
fetchStartedRef.current = false;
setBlocks([]);
setConfirmedHeight(null);
setMeasuredHeight(0);
}, [txHash]);
useEffect(() => {
fetchBlockData();
}, [fetchBlockData]);
const onMeasureLayout = useCallback((e: LayoutChangeEvent) => {
const height = Math.ceil(e.nativeEvent.layout.height);
if (height > 0) {
setMeasuredHeight(height);
}
}, []);
useEffect(() => {
const target = isExpanded ? (measuredHeight > 0 ? measuredHeight : BLOCKS_HEIGHT) : COLLAPSED_HEIGHT;
animatedHeight.value = withTiming(target, { duration: ANIMATION_DURATION, easing: ANIMATION_EASING });
}, [isExpanded, measuredHeight, animatedHeight]);
const confirmedIndex = useMemo(() => {
if (confirmedHeight === null) return 0;
return blocks.findIndex(b => b.height === confirmedHeight);
}, [blocks, confirmedHeight]);
const containerWidth = windowWidth - STATE_CARD_MARGIN_H * 2;
const initialOffset = useMemo(() => {
if (confirmedIndex <= 0) return 0;
const itemCenter = HORIZONTAL_PADDING + confirmedIndex * ITEM_LENGTH + BLOCK_CARD_WIDTH / 2;
return Math.max(0, itemCenter - containerWidth / 2);
}, [confirmedIndex, containerWidth]);
const animatedContentStyle = useAnimatedStyle(() => ({
height: animatedHeight.value,
overflow: 'hidden' as const,
}));
const blocksAgoText = useMemo(() => {
const blocksAgo = Math.max(0, confirmations - 1);
if (blocksAgo === 1) {
return loc.transactions.block_ago;
}
return loc.formatString(loc.transactions.blocks_ago, { count: String(blocksAgo) });
}, [confirmations]);
const summaryContent = useMemo(() => {
if (confirmedHeight === null) return null;
const confirmationParts = renderBoldFormattedParts(
loc.transactions.blocks_confirmed_summary,
{
blocksAgo: blocksAgoText,
blockHeight: String(confirmedHeight),
},
stylesHook.summaryBold,
);
const hasFeeDetails = vsize != null && feeRate != null && feeSats != null;
if (!hasFeeDetails) {
return <Text style={[styles.summaryText, stylesHook.summaryText]}>{confirmationParts}</Text>;
}
const feeDisplay = `${formatBalanceWithoutSuffix(feeSats, BitcoinUnit.SATS, true)} sats`;
const feeParts = renderBoldFormattedParts(
loc.transactions.blocks_confirmed_fee_summary,
{
vsize: `${vsize} vb`,
feeRate: `${Number(feeRate.toFixed(1))} sats/vb`,
fee: feeDisplay,
},
stylesHook.summaryBold,
);
return (
<Text style={[styles.summaryText, stylesHook.summaryText]}>
{confirmationParts} {feeParts}
</Text>
);
}, [blocksAgoText, confirmedHeight, feeRate, feeSats, stylesHook.summaryBold, stylesHook.summaryText, vsize]);
const keyExtractor = useCallback((item: BlockData) => String(item.height), []);
const renderBlock = useCallback(
({ item }: ListRenderItemInfo<BlockData>) => {
const isConfirmed = confirmedHeight !== null && item.height === confirmedHeight;
return (
<View style={[styles.blockCard, stylesHook.blockCardBase, isConfirmed && stylesHook.confirmedBlockCard]}>
{isConfirmed && (
<>
<LottieView
style={styles.blockLottie}
source={txblockAnimation}
autoPlay
loop
resizeMode="cover"
colorFilters={lottieColorFilters}
/>
<LinearGradient
colors={blockGradientColors}
locations={[0, 0.3]}
start={BLOCK_GRADIENT_TOP_START}
end={BLOCK_GRADIENT_TOP_END}
style={styles.blockGradient}
pointerEvents="none"
/>
<LinearGradient
colors={blockGradientColors}
locations={[0.02, 0.3]}
start={BLOCK_GRADIENT_BOTTOM_START}
end={BLOCK_GRADIENT_BOTTOM_END}
style={styles.blockGradient}
pointerEvents="none"
/>
</>
)}
<Text style={[styles.blockHeight, stylesHook.summaryText]}>{item.height}</Text>
<View style={styles.blockDateContainer}>
<Text style={[styles.blockDate, stylesHook.summaryText]}>
{item.timestamp ? dayjs(item.timestamp * 1000).format('DD/MM/YYYY') : '-'}
</Text>
</View>
</View>
);
},
[
blockGradientColors,
confirmedHeight,
lottieColorFilters,
stylesHook.blockCardBase,
stylesHook.confirmedBlockCard,
stylesHook.summaryText,
],
);
const measureContent = loading ? (
<View style={styles.loadingContainer} />
) : (
<>
{summaryContent && <View style={styles.summaryContainer}>{summaryContent}</View>}
<View style={styles.blocksList} />
</>
);
const visibleContent = loading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator color={accentColor} />
</View>
) : (
<>
{summaryContent && (
<TouchableOpacity onPress={onPress} activeOpacity={0.7} disabled={!onPress}>
<View style={styles.summaryContainer}>{summaryContent}</View>
</TouchableOpacity>
)}
<FlatList
data={blocks}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
keyExtractor={keyExtractor}
renderItem={renderBlock}
getItemLayout={getItemLayout}
contentOffset={{ x: initialOffset, y: 0 }}
snapToInterval={ITEM_LENGTH}
decelerationRate="fast"
style={styles.blocksList}
/>
</>
);
return (
<>
<View style={styles.measureLayer} onLayout={onMeasureLayout} pointerEvents="none">
{measureContent}
</View>
<Animated.View style={animatedContentStyle}>{visibleContent}</Animated.View>
</>
);
};
const styles = StyleSheet.create({
measureLayer: {
position: 'absolute',
opacity: 0,
width: '100%',
zIndex: -1,
},
loadingContainer: {
justifyContent: 'center',
alignItems: 'center',
minHeight: BLOCKS_HEIGHT,
},
summaryContainer: {
paddingHorizontal: 16,
paddingTop: 0,
paddingBottom: 8,
},
summaryText: {
fontSize: 13,
lineHeight: 16,
fontWeight: '500',
letterSpacing: -0.5,
},
blocksList: {
height: BLOCKS_HEIGHT,
},
scrollContent: {
paddingHorizontal: HORIZONTAL_PADDING,
paddingVertical: 8,
},
blockCard: {
width: BLOCK_CARD_WIDTH,
height: 110,
borderRadius: 12,
padding: 12,
justifyContent: 'space-between',
borderWidth: 1,
borderColor: 'transparent',
marginRight: BLOCK_CARD_GAP,
overflow: 'hidden',
},
blockLottie: {
...StyleSheet.absoluteFill,
},
blockGradient: {
...StyleSheet.absoluteFill,
},
blockHeight: {
fontSize: 15,
fontWeight: '700',
},
blockDateContainer: {
marginTop: 'auto',
},
blockDate: {
fontSize: 13,
fontWeight: '500',
},
});
export default BlocksAccordion;

View File

@ -28,10 +28,10 @@ export const BlueDefaultTheme = {
buttonBlueBackgroundColor: '#ccddf9',
buttonGrayBackgroundColor: '#EEEEEE',
incomingBackgroundColor: '#d2f8d6',
incomingForegroundColor: '#37c0a1',
incomingForegroundColor: '#1e8a6a',
outgoingBackgroundColor: '#f8d2d2',
outgoingForegroundColor: '#d0021b',
successColor: '#37c0a1',
successColor: '#1e8a6a',
failedColor: '#ff0000',
placeholderTextColor: '#81868e',
shadowColor: '#000000',
@ -53,7 +53,7 @@ export const BlueDefaultTheme = {
scanLabel: '#9AA0AA',
feeText: '#81868e',
feeLabel: '#d2f8d6',
feeValue: '#37c0a1',
feeValue: '#1e8a6a',
feeActive: '#d2f8d6',
labelText: '#81868e',
cta2: '#062453',
@ -62,7 +62,7 @@ export const BlueDefaultTheme = {
mainColor: '#CFDCF6',
success: '#ccddf9',
successCheck: '#0f5cc0',
msSuccessBG: '#37c0a1',
msSuccessBG: '#1e8a6a',
msSuccessCheck: '#ffffff',
newBlue: '#007AFF',
redBG: '#F8D2D2',
@ -70,7 +70,7 @@ export const BlueDefaultTheme = {
changeBackground: '#FDF2DA',
changeText: '#F38C47',
receiveBackground: '#D1F9D6',
receiveText: '#37C0A1',
receiveText: '#1e8a6a',
androidRippleColor: '#CCCCCC',
transactionPendingColor: '#2757C6',
transactionPendingBackgroundColor: '#DBEFFD',
@ -79,7 +79,7 @@ export const BlueDefaultTheme = {
transactionStateBumpButtonBackground: 'rgba(255, 255, 255, 0.4)',
transactionStateCancelButtonBackground: 'rgba(0, 0, 0, 0.05)',
transactionSentColor: '#BF2828',
transactionReceivedColor: '#63BDA2',
transactionReceivedColor: '#1e8a6a',
cardSectionBackground: '#F9F9F9',
cardSectionHeaderBackground: '#F2F2F2',
cardBorderColor: 'rgba(0, 0, 0, 0.05)',

1
img/txblock.json Normal file

File diff suppressed because one or more lines are too long

View File

@ -339,6 +339,10 @@
"transaction_loading_error": "There was an issue loading the transaction. Please try again later.",
"transaction_not_available": "Transaction not available",
"confirmations_lowercase": "{confirmations} confirmations",
"blocks_ago": "{count} blocks ago",
"block_ago": "1 block ago",
"blocks_confirmed_summary": "Confirmed {blocksAgo} on block {blockHeight}.",
"blocks_confirmed_fee_summary": "With a size of {vsize} and a fee rate of {feeRate}, paying {fee} fee.",
"expand_note": "Expand Note",
"cpfp_create": "Create",
"cpfp_exp": "We will create another transaction that spends your unconfirmed transaction. The total fee will be higher than the original transaction fee, so it should be mined faster. This is called CPFP—Child Pays for Parent.",

View File

@ -5,6 +5,7 @@ import { RouteProp, useRoute } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import Icon from '../../components/Icon';
import dayjs from 'dayjs';
import Animated, { useAnimatedStyle, useSharedValue, withSequence, withTiming } from 'react-native-reanimated';
import relativeTime from 'dayjs/plugin/relativeTime';
import * as BlueElectrum from '../../blue_modules/BlueElectrum';
import { satoshiToLocalCurrency } from '../../blue_modules/currency';
@ -22,6 +23,7 @@ import CopyTextToClipboard from '../../components/CopyTextToClipboard';
import TransactionIncomingIcon from '../../components/icons/TransactionIncomingIcon';
import TransactionOutgoingIcon from '../../components/icons/TransactionOutgoingIcon';
import TransactionPendingIcon from '../../components/icons/TransactionPendingIcon';
import BlocksAccordion from '../../components/BlocksAccordion';
import SafeAreaScrollView from '../../components/SafeAreaScrollView';
import { useTheme } from '../../components/themes';
import prompt from '../../helpers/prompt';
@ -161,6 +163,17 @@ const TransactionStatus: React.FC = () => {
const detailValueMaxWidth = useMemo(() => Math.max(0, Math.floor((windowWidth - 48) / 2)), [windowWidth]);
const detailValueWidthStyle = useMemo(() => ({ width: detailValueMaxWidth }), [detailValueMaxWidth]);
// Blocks accordion state
const [isBlocksExpanded, setIsBlocksExpanded] = useState(false);
const stateCardScale = useSharedValue(1);
const stateCardAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: stateCardScale.value }],
}));
const toggleBlocksExpanded = useCallback(() => {
setIsBlocksExpanded(prev => !prev);
stateCardScale.value = withSequence(withTiming(0.98, { duration: 100 }), withTiming(1, { duration: 150 }));
}, [stateCardScale]);
// Advanced section state
const [isAdvancedExpanded, setIsAdvancedExpanded] = useState(false);
const [txHex, setTxHex] = useState<string | null>(null);
@ -838,6 +851,14 @@ const TransactionStatus: React.FC = () => {
const isPending = resolveTxDisplayState(tx) === 'pending';
const preferredBalanceUnit = wallet?.preferredBalanceUnit ?? BitcoinUnit.BTC;
const showBlocksAccordion = !isPending && parsedConfirmations > 0;
const onBlocksHeaderPress = useCallback(() => {
if (!showBlocksAccordion) return;
triggerHapticFeedback(HapticFeedbackTypes.ImpactLight);
toggleBlocksExpanded();
}, [showBlocksAccordion, toggleBlocksExpanded]);
// Get transaction direction and date
const transactionDirection = txValue !== null && txValue < 0 ? loc.transactions.details_sent : loc.transactions.details_received;
const transactionDate = tx?.timestamp ? dayjs(tx.timestamp * 1000).format('LLL') : '-';
@ -980,7 +1001,7 @@ const TransactionStatus: React.FC = () => {
</View>
{/* State Section */}
<View
<Animated.View
style={[
styles.stateCard,
isPending
@ -988,6 +1009,7 @@ const TransactionStatus: React.FC = () => {
: txValue !== null && txValue < 0
? stylesHook.stateCardSent
: stylesHook.stateCardReceived,
stateCardAnimatedStyle,
]}
>
<View style={styles.stateSection}>
@ -1026,36 +1048,58 @@ const TransactionStatus: React.FC = () => {
)}
</>
) : txValue !== null && txValue < 0 ? (
<View style={styles.stateIndicator}>
<TransactionOutgoingIcon />
<View style={styles.stateLabelContainer}>
<BlueText style={[styles.stateLabel, stylesHook.stateLabelSent]}>{loc.transactions.details_sent}</BlueText>
{isOnChainTx && (
<BlueText style={[styles.stateValue, stylesHook.stateValueSent, styles.stateValueInline]}>
{loc.formatString(loc.transactions.confirmations_lowercase, {
confirmations: parsedConfirmations > 6 ? '6+' : parsedConfirmations,
})}
</BlueText>
)}
<TouchableOpacity style={styles.stateHeaderRow} onPress={onBlocksHeaderPress} activeOpacity={0.7}>
<View style={styles.stateIndicator}>
<TransactionOutgoingIcon />
<View style={styles.stateLabelContainer}>
<BlueText style={[styles.stateLabel, stylesHook.stateLabelSent]}>{loc.transactions.details_sent}</BlueText>
{isOnChainTx && (
<BlueText style={[styles.stateValue, stylesHook.stateValueSent, styles.stateValueInline]}>
{loc.formatString(loc.transactions.confirmations_lowercase, {
confirmations: parsedConfirmations > 6 ? '6+' : parsedConfirmations,
})}
</BlueText>
)}
</View>
</View>
</View>
{showBlocksAccordion && (
<Icon name="information-circle-outline" type="ionicons" size={20} color={colors.transactionSentColor} />
)}
</TouchableOpacity>
) : (
<View style={styles.stateIndicator}>
<TransactionIncomingIcon />
<View style={styles.stateLabelContainer}>
<BlueText style={[styles.stateLabel, stylesHook.stateLabelReceived]}>{loc.transactions.details_received}</BlueText>
{isOnChainTx && (
<BlueText style={[styles.stateValue, stylesHook.stateValueReceived, styles.stateValueInline]}>
{loc.formatString(loc.transactions.confirmations_lowercase, {
confirmations: parsedConfirmations > 6 ? '6+' : parsedConfirmations,
})}
</BlueText>
)}
<TouchableOpacity style={styles.stateHeaderRow} onPress={onBlocksHeaderPress} activeOpacity={0.7}>
<View style={styles.stateIndicator}>
<TransactionIncomingIcon />
<View style={styles.stateLabelContainer}>
<BlueText style={[styles.stateLabel, stylesHook.stateLabelReceived]}>{loc.transactions.details_received}</BlueText>
{isOnChainTx && (
<BlueText style={[styles.stateValue, stylesHook.stateValueReceived, styles.stateValueInline]}>
{loc.formatString(loc.transactions.confirmations_lowercase, {
confirmations: parsedConfirmations > 6 ? '6+' : parsedConfirmations,
})}
</BlueText>
)}
</View>
</View>
</View>
{showBlocksAccordion && (
<Icon name="information-circle-outline" type="ionicons" size={20} color={colors.transactionReceivedColor} />
)}
</TouchableOpacity>
)}
</View>
</View>
{showBlocksAccordion && tx?.hash && (
<BlocksAccordion
txHash={tx.hash}
isSent={txValue !== null && txValue < 0}
isExpanded={isBlocksExpanded}
confirmations={parsedConfirmations}
vsize={tx.vsize}
feeSats={calculatedFee}
feeRate={feeRate}
onPress={onBlocksHeaderPress}
/>
)}
</Animated.View>
{/* Counterparty badge (read-only, matches contact list style) */}
{counterpartyDisplayName && (
@ -1191,7 +1235,10 @@ const TransactionStatus: React.FC = () => {
{/* Advanced Section */}
<View style={[styles.detailsCard, stylesHook.detailsCard]}>
<TouchableOpacity
onPress={() => setIsAdvancedExpanded(!isAdvancedExpanded)}
onPress={() => {
triggerHapticFeedback(HapticFeedbackTypes.ImpactLight);
setIsAdvancedExpanded(!isAdvancedExpanded);
}}
style={[styles.advancedHeader, stylesHook.advancedHeader]}
activeOpacity={0.85}
>
@ -1391,10 +1438,17 @@ const styles = StyleSheet.create({
paddingBottom: 16,
paddingHorizontal: 20,
},
stateHeaderRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
},
stateIndicator: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 6,
flex: 1,
},
stateLabelContainer: {
flexDirection: 'column',

View File

@ -153,11 +153,32 @@ jest.mock('../../loc', () => ({
details_from: 'From',
txid: 'txid',
details_received: 'received',
details_sent: 'sent',
details_inputs: 'inputs',
details_outputs: 'outputs',
details_inputs_count: 'Inputs ({count})',
details_outputs_count: 'Outputs ({count})',
details_view_in_browser: 'view in browser',
details_note: 'Note',
details_add_note: 'Add note',
details_section: 'Details',
details_explorer: 'Explorer',
details_network_fee: 'Network Fee',
details_to_address: 'To',
details_id: 'ID',
details_fee_rate: 'Fee Rate',
details_size: 'Size',
details_virtual_size: 'Virtual Size',
details_tx_hex: 'TX Hex',
details_copy: 'Copy',
details_advanced: 'Advanced',
details_eta_analyzing: 'Analyzing...',
pending: 'Pending',
open_url_error: 'Unable to open URL',
blocks_ago: '{count} blocks ago',
block_ago: '1 block ago',
blocks_confirmed_summary: 'Confirmed {blocksAgo} on block {blockHeight}.',
blocks_confirmed_fee_summary: 'With a size of {vsize} and a fee rate of {feeRate}, paying {fee} fee.',
},
send: {
create_details: 'Details',