Compare commits
11 Commits
master
...
feat-confb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b56b40d6c | ||
|
|
cdec49160f | ||
|
|
e8f1fc6786 | ||
|
|
4c3e8764a0 | ||
|
|
9aca00ae56 | ||
|
|
593e410a20 | ||
|
|
f323f5132b | ||
|
|
9db0b759fd | ||
|
|
b2e8c01011 | ||
|
|
dd71e0ff60 | ||
|
|
b943d9f641 |
@ -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 68–71 of the 80-byte header (hex chars 136–143), 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;
|
||||
|
||||
397
components/BlocksAccordion.tsx
Normal file
397
components/BlocksAccordion.tsx
Normal 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;
|
||||
@ -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
1
img/txblock.json
Normal file
File diff suppressed because one or more lines are too long
@ -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.",
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user