Compare commits
11 Commits
master
...
feat-confb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b56b40d6c | ||
|
|
cdec49160f | ||
|
|
e8f1fc6786 | ||
|
|
4c3e8764a0 | ||
|
|
9aca00ae56 | ||
|
|
593e410a20 | ||
|
|
f323f5132b | ||
|
|
9db0b759fd | ||
|
|
b2e8c01011 | ||
|
|
dd71e0ff60 | ||
|
|
b943d9f641 |
2
Gemfile
2
Gemfile
@ -7,7 +7,7 @@ gem "fastlane", "~> 2.234.0"
|
||||
gem 'cocoapods', '>= 1.13', '!= 1.15.0', '!= 1.15.1'
|
||||
gem 'activesupport', '>= 6.1.7.5', '!= 7.1.0'
|
||||
gem 'xcodeproj', '< 1.26.0'
|
||||
gem 'concurrent-ruby', '< 1.3.8'
|
||||
gem 'concurrent-ruby', '< 1.3.4'
|
||||
|
||||
# Ruby 3.4.0 removed these from the standard library
|
||||
gem 'bigdecimal'
|
||||
|
||||
@ -87,7 +87,7 @@ GEM
|
||||
colored2 (3.1.2)
|
||||
commander (4.6.0)
|
||||
highline (~> 2.0.0)
|
||||
concurrent-ruby (1.3.7)
|
||||
concurrent-ruby (1.3.3)
|
||||
connection_pool (3.0.2)
|
||||
csv (3.3.5)
|
||||
declarative (0.0.20)
|
||||
@ -337,7 +337,7 @@ DEPENDENCIES
|
||||
benchmark
|
||||
bigdecimal
|
||||
cocoapods (>= 1.13, != 1.15.1, != 1.15.0)
|
||||
concurrent-ruby (< 1.3.8)
|
||||
concurrent-ruby (< 1.3.4)
|
||||
fastlane (~> 2.234.0)
|
||||
fastlane-plugin-browserstack
|
||||
fastlane-plugin-bugsnag
|
||||
@ -377,7 +377,7 @@ CHECKSUMS
|
||||
colored (1.2) sha256=9d82b47ac589ce7f6cab64b1f194a2009e9fd00c326a5357321f44afab2c1d2c
|
||||
colored2 (3.1.2) sha256=b13c2bd7eeae2cf7356a62501d398e72fde78780bd26aec6a979578293c28b4a
|
||||
commander (4.6.0) sha256=7d1ddc3fccae60cc906b4131b916107e2ef0108858f485fdda30610c0f2913d9
|
||||
concurrent-ruby (1.3.7) sha256=4412caec3a5ea2e5fdc52076724c071a81f2c0593d83b2ac8cbb8ca63b3151b0
|
||||
concurrent-ruby (1.3.3) sha256=4f9cd28965c4dcf83ffd3ea7304f9323277be8525819cb18a3b61edcb56a7c6a
|
||||
connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a
|
||||
csv (3.3.5) sha256=6e5134ac3383ef728b7f02725d9872934f523cb40b961479f69cf3afa6c8e73f
|
||||
declarative (0.0.20) sha256=8021dd6cb17ab2b61233c56903d3f5a259c5cf43c80ff332d447d395b17d9ff9
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -1,98 +1,23 @@
|
||||
import { cbc } from '@noble/ciphers/aes';
|
||||
import { md5 } from '@noble/hashes/legacy';
|
||||
import { randomBytes } from '@noble/hashes/utils';
|
||||
import AES from 'crypto-js/aes';
|
||||
import Utf8 from 'crypto-js/enc-utf8';
|
||||
|
||||
import { areUint8ArraysEqual, base64ToUint8Array, concatUint8Arrays, stringToUint8Array, uint8ArrayToBase64 } from './uint8array-extras';
|
||||
|
||||
/**
|
||||
* OpenSSL EVP_BytesToKey using MD5 with 1 iteration.
|
||||
*
|
||||
* Reproduces the default key+IV derivation used by CryptoJS@4.x's
|
||||
* `AES.encrypt(string, password)` so the on-disk wire format stays
|
||||
* bit-identical after we swap the underlying library.
|
||||
*
|
||||
* D1 = MD5( password || salt )
|
||||
* Di = MD5( D(i-1) || password || salt ) for i ≥ 2
|
||||
* key||iv = D1 || D2 || ... (take first `byteLength` bytes)
|
||||
*
|
||||
* MD5 is intentional: it matches the legacy OpenSSL format. The
|
||||
* cryptographic weakness of MD5 is not relevant here — the function is
|
||||
* only used as a deterministic byte-stretcher; the password's entropy is
|
||||
* what protects the wallet, not MD5.
|
||||
*/
|
||||
export function evpBytesToKeyMd5(password: Uint8Array, salt: Uint8Array, byteLength: number): Uint8Array {
|
||||
if (!Number.isInteger(byteLength) || byteLength < 0) {
|
||||
throw new Error('evpBytesToKeyMd5: byteLength must be a non-negative integer');
|
||||
}
|
||||
const out = new Uint8Array(byteLength);
|
||||
let written = 0;
|
||||
let prev: Uint8Array = new Uint8Array(0);
|
||||
while (written < byteLength) {
|
||||
prev = md5(concatUint8Arrays([prev, password, salt]));
|
||||
const take = Math.min(prev.length, byteLength - written);
|
||||
out.set(prev.subarray(0, take), written);
|
||||
written += take;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// "Salted__" — OpenSSL envelope magic. Hardcoded as bytes so the wire
|
||||
// format cannot drift through any encoder.
|
||||
const SALT_MAGIC = new Uint8Array([0x53, 0x61, 0x6c, 0x74, 0x65, 0x64, 0x5f, 0x5f]);
|
||||
const SALT_LEN = 8;
|
||||
const KEY_LEN = 32;
|
||||
const IV_LEN = 16;
|
||||
const BLOCK_LEN = 16;
|
||||
|
||||
/**
|
||||
* AES-256-CBC encrypt with the OpenSSL "Salted__" envelope, EVP_BytesToKey-MD5
|
||||
* key derivation and PKCS7 padding. Output is base64-encoded.
|
||||
*
|
||||
* Wire format is bit-identical to CryptoJS@4.x's default
|
||||
* `AES.encrypt(data, password).toString()` — we kept the swap-the-library
|
||||
* change a drop-in replacement so existing encrypted wallets on user
|
||||
* devices remain readable, with no migration step.
|
||||
*/
|
||||
export function encrypt(data: string, password: string): string {
|
||||
if (data.length < 10) throw new Error('data length cant be < 10');
|
||||
const salt = randomBytes(SALT_LEN);
|
||||
const kdf = evpBytesToKeyMd5(stringToUint8Array(password), salt, KEY_LEN + IV_LEN);
|
||||
const key = kdf.subarray(0, KEY_LEN);
|
||||
const iv = kdf.subarray(KEY_LEN);
|
||||
const ciphertext = cbc(key, iv).encrypt(stringToUint8Array(data));
|
||||
return uint8ArrayToBase64(concatUint8Arrays([SALT_MAGIC, salt, ciphertext]));
|
||||
const ciphertext = AES.encrypt(data, password);
|
||||
return ciphertext.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse of `encrypt`. Accepts the legacy CryptoJS wire format and returns
|
||||
* the original UTF-8 plaintext. Any error (bad base64, missing magic, wrong
|
||||
* password, bad padding) collapses to `false`.
|
||||
*/
|
||||
export function decrypt(data: string, password: string): string | false {
|
||||
const bytes = AES.decrypt(data, password);
|
||||
let str: string | false = false;
|
||||
try {
|
||||
// crypto-js's base64 decoder ignored whitespace. Some old encrypted-backup
|
||||
// export/import flows (manual file paste, clipboard transit, email-based
|
||||
// wallet transfer) introduced stray newlines or padding spaces. Strip them
|
||||
// before strict base64 decode so legacy backups still open. `\s` does not
|
||||
// include `=`, so base64 padding survives.
|
||||
const envelope = base64ToUint8Array(data.replace(/\s+/g, ''));
|
||||
if (envelope.length < SALT_MAGIC.length + SALT_LEN + BLOCK_LEN) return false;
|
||||
if (!areUint8ArraysEqual(envelope.subarray(0, SALT_MAGIC.length), SALT_MAGIC)) return false;
|
||||
const salt = envelope.subarray(SALT_MAGIC.length, SALT_MAGIC.length + SALT_LEN);
|
||||
const ciphertext = envelope.subarray(SALT_MAGIC.length + SALT_LEN);
|
||||
const kdf = evpBytesToKeyMd5(stringToUint8Array(password), salt, KEY_LEN + IV_LEN);
|
||||
const key = kdf.subarray(0, KEY_LEN);
|
||||
const iv = kdf.subarray(KEY_LEN);
|
||||
const plain = cbc(key, iv).decrypt(ciphertext);
|
||||
// Strict UTF-8 decode — wrong-password decrypts that happen to survive
|
||||
// PKCS7 unpadding overwhelmingly fail here (crypto-js's `enc.Utf8` was
|
||||
// strict too; we preserve that gate by using `fatal: true`).
|
||||
const str = new TextDecoder('utf-8', { fatal: true }).decode(plain);
|
||||
// Belt-and-suspenders: legitimate plaintext is always ≥ 10 chars
|
||||
// (enforced by encrypt()), so anything shorter is rejected.
|
||||
if (str.length < 10) return false;
|
||||
return str;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
str = bytes.toString(Utf8);
|
||||
} catch (e) {}
|
||||
|
||||
// For some reason, sometimes decrypt would succeed with an incorrect password and return random characters.
|
||||
// In this TypeScript version, we are not allowing the encryption of data that is shorter than
|
||||
// 10 characters. If the decrypted data is less than 10 characters, we assume that the decrypt actually failed.
|
||||
if (str && str.length < 10) return false;
|
||||
|
||||
return str;
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@ import { bech32 } from 'bech32';
|
||||
import bolt11 from 'bolt11';
|
||||
import { sha256 } from '@noble/hashes/sha256';
|
||||
import { hmac } from '@noble/hashes/hmac';
|
||||
import { cbc } from '@noble/ciphers/aes';
|
||||
import CryptoJS from 'crypto-js';
|
||||
import ecc from '../blue_modules/noble_ecc';
|
||||
import { parse } from 'url'; // eslint-disable-line n/no-deprecated-api
|
||||
import { fetch } from '../util/fetch';
|
||||
@ -321,24 +321,13 @@ export default class Lnurl {
|
||||
}
|
||||
|
||||
static decipherAES(ciphertextBase64: string, preimageHex: string, ivBase64: string): string {
|
||||
// crypto-js's old implementation silently returned '' on malformed
|
||||
// ciphertext (non-16-aligned bytes, bad PKCS7 padding) and threw on
|
||||
// malformed UTF-8 plaintext. @noble/ciphers throws on the former. We
|
||||
// catch every throw and return '' — the call site at
|
||||
// screen/lnd/lnurlPaySuccess.tsx renders this directly without a
|
||||
// try/catch, so a misbehaving LNURL server should not crash the screen.
|
||||
// Note: unlike crypto-js's strict `enc.Utf8` decoder, `uint8ArrayToString`
|
||||
// is lenient on bad UTF-8 (mojibake instead of throw); this is strictly
|
||||
// safer than the old behaviour for this user-facing path.
|
||||
try {
|
||||
const key = hexToUint8Array(preimageHex);
|
||||
const iv = base64ToUint8Array(ivBase64);
|
||||
const ct = base64ToUint8Array(ciphertextBase64);
|
||||
const pt = cbc(key, iv).decrypt(ct);
|
||||
return uint8ArrayToString(pt);
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
const iv = CryptoJS.enc.Base64.parse(ivBase64);
|
||||
const key = CryptoJS.enc.Hex.parse(preimageHex);
|
||||
return CryptoJS.AES.decrypt(uint8ArrayToHex(base64ToUint8Array(ciphertextBase64)), key, {
|
||||
iv,
|
||||
mode: CryptoJS.mode.CBC,
|
||||
format: CryptoJS.format.Hex,
|
||||
}).toString(CryptoJS.enc.Utf8);
|
||||
}
|
||||
|
||||
getCommentAllowed(): number | false {
|
||||
|
||||
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;
|
||||
@ -52,14 +52,7 @@ const useFloatButtonAnimation = (initialHeight: number) => {
|
||||
};
|
||||
};
|
||||
|
||||
const getScaledButtonHeight = (fontScale: number): number => Math.round(LAYOUT.BUTTON_HEIGHT * fontScale);
|
||||
|
||||
/** Scroll padding so list content clears float buttons (excludes safe-area inset). Default 70 at fontScale 1. */
|
||||
const FLOAT_BUTTON_LIST_CLEARANCE = 18;
|
||||
|
||||
export const getFloatingButtonReservedHeight = (fontScale = 1): number => getScaledButtonHeight(fontScale) + FLOAT_BUTTON_LIST_CLEARANCE;
|
||||
|
||||
const useFloatButtonLayout = (width: number, sizeClass: SizeClass, fontScale: number) => {
|
||||
const useFloatButtonLayout = (width: number, sizeClass: SizeClass) => {
|
||||
const lastVerticalDecision = useRef(false);
|
||||
|
||||
const shouldUseVerticalLayout = useCallback(
|
||||
@ -159,19 +152,15 @@ const useFloatButtonLayout = (width: number, sizeClass: SizeClass, fontScale: nu
|
||||
[width, sizeClass, shouldUseVerticalLayout],
|
||||
);
|
||||
|
||||
const calculateContainerHeight = useCallback(
|
||||
(childrenCount: number, isVerticalLayout: boolean) => {
|
||||
const buttonHeight = getScaledButtonHeight(fontScale);
|
||||
if (!isVerticalLayout) return { height: '8%', minHeight: buttonHeight };
|
||||
const calculateContainerHeight = useCallback((childrenCount: number, isVerticalLayout: boolean) => {
|
||||
if (!isVerticalLayout) return { height: '8%', minHeight: LAYOUT.BUTTON_HEIGHT };
|
||||
|
||||
const totalButtonsHeight = childrenCount * buttonHeight;
|
||||
const totalMarginsHeight = (childrenCount - 1) * LAYOUT.BUTTON_MARGIN;
|
||||
const calculatedHeight = totalButtonsHeight + totalMarginsHeight;
|
||||
const totalButtonsHeight = childrenCount * LAYOUT.BUTTON_HEIGHT;
|
||||
const totalMarginsHeight = (childrenCount - 1) * LAYOUT.BUTTON_MARGIN;
|
||||
const calculatedHeight = totalButtonsHeight + totalMarginsHeight;
|
||||
|
||||
return { height: calculatedHeight };
|
||||
},
|
||||
[fontScale],
|
||||
);
|
||||
return { height: calculatedHeight };
|
||||
}, []);
|
||||
|
||||
const calculateButtonFontSize = useMemo(() => {
|
||||
const divisor = sizeClass === SizeClass.Large ? 22 : sizeClass === SizeClass.Regular ? 24 : 28;
|
||||
@ -278,7 +267,6 @@ interface FButtonProps {
|
||||
isVertical?: boolean;
|
||||
borderRadius?: number;
|
||||
fontSize?: number;
|
||||
buttonHeight?: number;
|
||||
disabled?: boolean;
|
||||
testID?: string;
|
||||
onPress: () => void;
|
||||
@ -289,14 +277,13 @@ interface ButtonContentProps {
|
||||
icon: ReactNode;
|
||||
text: string;
|
||||
textStyle: StyleProp<TextStyle>;
|
||||
buttonHeight: number;
|
||||
}
|
||||
|
||||
const getScaledIconSize = (fontSize: number): number => {
|
||||
return Math.max(Math.round(fontSize * 1.2), 16);
|
||||
};
|
||||
|
||||
const ButtonContent = ({ icon, text, textStyle, buttonHeight }: ButtonContentProps) => {
|
||||
const ButtonContent = ({ icon, text, textStyle }: ButtonContentProps) => {
|
||||
const computedStyle = StyleSheet.flatten(textStyle);
|
||||
const fontSize = computedStyle.fontSize || LAYOUT.MAX_BUTTON_FONT_SIZE;
|
||||
const iconSize = getScaledIconSize(Number(fontSize));
|
||||
@ -320,14 +307,9 @@ const ButtonContent = ({ icon, text, textStyle, buttonHeight }: ButtonContentPro
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[buttonContentStaticStyles.contentContainer, { minHeight: buttonHeight }]}>
|
||||
<View style={buttonContentStaticStyles.contentContainer}>
|
||||
<View style={buttonStyles.iconContainer}>{scaledIcon}</View>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
adjustsFontSizeToFit
|
||||
minimumFontScale={0.8}
|
||||
style={[textStyle, buttonStyles.centeredText, { lineHeight: fontSize * 1.2 }]}
|
||||
>
|
||||
<Text numberOfLines={1} adjustsFontSizeToFit style={[textStyle, buttonStyles.centeredText, { lineHeight: fontSize * 1.2 }]}>
|
||||
{text}
|
||||
</Text>
|
||||
</View>
|
||||
@ -343,7 +325,6 @@ export const FButton = ({
|
||||
isVertical,
|
||||
borderRadius = LAYOUT.PILL_BORDER_RADIUS,
|
||||
fontSize = LAYOUT.MAX_BUTTON_FONT_SIZE,
|
||||
buttonHeight = LAYOUT.BUTTON_HEIGHT,
|
||||
testID,
|
||||
...props
|
||||
}: FButtonProps) => {
|
||||
@ -366,8 +347,6 @@ export const FButton = ({
|
||||
return {
|
||||
root: {
|
||||
...baseStyles,
|
||||
height: buttonHeight,
|
||||
minHeight: buttonHeight,
|
||||
backgroundColor: colors.buttonBackgroundColor,
|
||||
},
|
||||
text: {
|
||||
@ -381,7 +360,7 @@ export const FButton = ({
|
||||
marginBottom: buttonContentStaticStyles.marginBottom,
|
||||
textBase: buttonContentStaticStyles.textBase,
|
||||
};
|
||||
}, [colors, fontSize, buttonHeight]);
|
||||
}, [colors, fontSize]);
|
||||
|
||||
const style: Record<string, any> = {};
|
||||
const additionalStyles = !last ? (isVertical ? customButtonStyles.marginBottom : customButtonStyles.marginRight) : {};
|
||||
@ -418,7 +397,7 @@ export const FButton = ({
|
||||
style={[buttonStyles.root, customButtonStyles.root, style, { borderRadius }]}
|
||||
{...props}
|
||||
>
|
||||
<ButtonContent icon={icon} text={text} textStyle={textStyle} buttonHeight={buttonHeight} />
|
||||
<ButtonContent icon={icon} text={text} textStyle={textStyle} />
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
);
|
||||
@ -426,9 +405,8 @@ export const FButton = ({
|
||||
|
||||
export const FContainer = forwardRef<View, FContainerProps>((props, ref) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { height, width, fontScale } = useWindowDimensions();
|
||||
const { height, width } = useWindowDimensions();
|
||||
const { sizeClass } = useSizeClass();
|
||||
const scaledButtonHeight = getScaledButtonHeight(fontScale);
|
||||
|
||||
const childrenCount = React.Children.toArray(props.children).filter(Boolean).length;
|
||||
|
||||
@ -441,7 +419,6 @@ export const FContainer = forwardRef<View, FContainerProps>((props, ref) => {
|
||||
const { calculateButtonWidth, calculateVisualParameters, calculateContainerHeight, buttonFontSize } = useFloatButtonLayout(
|
||||
width,
|
||||
sizeClass,
|
||||
fontScale,
|
||||
);
|
||||
|
||||
// Compute initial geometry up-front so the slide-in animation starts at the final (computed) size,
|
||||
@ -531,7 +508,7 @@ export const FContainer = forwardRef<View, FContainerProps>((props, ref) => {
|
||||
|
||||
useEffect(() => {
|
||||
debouncedCalculateLayout();
|
||||
}, [debouncedCalculateLayout, width, height, childrenCount, sizeClass, fontScale]);
|
||||
}, [debouncedCalculateLayout, width, height, childrenCount, sizeClass]);
|
||||
|
||||
const onLayout = (event: { nativeEvent: { layout: { width: number } } }) => {
|
||||
const { width: currentLayoutWidth } = event.nativeEvent.layout;
|
||||
@ -568,7 +545,6 @@ export const FContainer = forwardRef<View, FContainerProps>((props, ref) => {
|
||||
isVertical,
|
||||
borderRadius: buttonBorderRadius,
|
||||
fontSize: buttonFontSize,
|
||||
buttonHeight: scaledButtonHeight,
|
||||
});
|
||||
};
|
||||
|
||||
@ -585,10 +561,10 @@ export const FContainer = forwardRef<View, FContainerProps>((props, ref) => {
|
||||
props.inline ? containerStyles.rootInline : containerStyles.rootAbsolute,
|
||||
bottomInsets,
|
||||
effectiveNewWidth ? (isVertical ? containerStyles.rootPostVertical : containerStyles.rootPost) : containerStyles.rootPre,
|
||||
isVertical ? containerHeight : { minHeight: scaledButtonHeight },
|
||||
isVertical ? containerHeight : null,
|
||||
{ transform: [{ translateY: slideAnimation }] },
|
||||
],
|
||||
[props.inline, bottomInsets, effectiveNewWidth, isVertical, containerHeight, slideAnimation, scaledButtonHeight],
|
||||
[props.inline, bottomInsets, effectiveNewWidth, isVertical, containerHeight, slideAnimation],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@ -1,13 +1,10 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Pressable, StyleProp, StyleSheet, Switch, SwitchProps, Text, TextStyle, useWindowDimensions, View, ViewStyle } from 'react-native';
|
||||
import { Pressable, StyleProp, StyleSheet, Switch, SwitchProps, Text, TextStyle, View, ViewStyle } from 'react-native';
|
||||
import { useLocale } from '@react-navigation/native';
|
||||
|
||||
import Icon from './Icon';
|
||||
import { useTheme } from './themes';
|
||||
|
||||
/** Base row height for transaction list `getItemLayout` (padding + title + subtitle at fontScale 1). */
|
||||
export const TX_ROW_BASE_HEIGHT = 64;
|
||||
|
||||
interface ListItemProps {
|
||||
leftAvatar?: React.JSX.Element;
|
||||
containerStyle?: StyleProp<ViewStyle>;
|
||||
@ -58,20 +55,12 @@ const ListItem: React.FC<ListItemProps> = React.memo(
|
||||
}: ListItemProps) => {
|
||||
const { colors } = useTheme();
|
||||
const { direction } = useLocale();
|
||||
const { fontScale } = useWindowDimensions();
|
||||
const isRtl = direction === 'rtl';
|
||||
const contentRowStyle = useMemo(
|
||||
() => ({
|
||||
paddingVertical: Math.round(12 * fontScale),
|
||||
}),
|
||||
[fontScale],
|
||||
);
|
||||
const stylesHook = StyleSheet.create({
|
||||
title: {
|
||||
color: disabled ? colors.buttonDisabledTextColor : colors.foregroundColor,
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
lineHeight: Math.round(22 * fontScale),
|
||||
writingDirection: direction,
|
||||
},
|
||||
rightMemoText: {
|
||||
@ -83,7 +72,7 @@ const ListItem: React.FC<ListItemProps> = React.memo(
|
||||
color: colors.alternativeTextColor,
|
||||
fontWeight: '400',
|
||||
paddingVertical: switchProps ? 8 : 0,
|
||||
lineHeight: Math.round(20 * fontScale),
|
||||
lineHeight: 20,
|
||||
fontSize: 14,
|
||||
marginTop: 2,
|
||||
},
|
||||
@ -104,7 +93,7 @@ const ListItem: React.FC<ListItemProps> = React.memo(
|
||||
const enableFeedback = !noFeedback && !!onPress && !disabled;
|
||||
|
||||
const renderContent = () => (
|
||||
<View style={[styles.contentRow, contentRowStyle]}>
|
||||
<View style={styles.contentRow}>
|
||||
{leftAvatar && (
|
||||
<View style={styles.leftAvatarContainer}>
|
||||
{leftAvatar}
|
||||
@ -125,14 +114,7 @@ const ListItem: React.FC<ListItemProps> = React.memo(
|
||||
{rightTitle || rightSubtitle ? (
|
||||
<View style={styles.rightColumn}>
|
||||
{rightTitle ? (
|
||||
<Text
|
||||
style={rightTitleStyle}
|
||||
numberOfLines={1}
|
||||
adjustsFontSizeToFit
|
||||
minimumFontScale={0.75}
|
||||
accessibilityRole="text"
|
||||
selectable={rightTitleSelectable}
|
||||
>
|
||||
<Text style={rightTitleStyle} numberOfLines={1} accessibilityRole="text" selectable={rightTitleSelectable}>
|
||||
{rightTitle}
|
||||
</Text>
|
||||
) : null}
|
||||
@ -210,20 +192,16 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
leftAvatarContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
alignSelf: 'center',
|
||||
},
|
||||
rightColumn: {
|
||||
marginStart: 8,
|
||||
flexShrink: 0,
|
||||
minWidth: 0,
|
||||
alignItems: 'flex-end',
|
||||
alignSelf: 'center',
|
||||
},
|
||||
rightMemoWrapper: {
|
||||
flexShrink: 1,
|
||||
|
||||
@ -67,25 +67,26 @@ const ReplaceFeeSuggestions: React.FC<ReplaceFeeSuggestionsProps> = ({ onFeeSele
|
||||
}, []);
|
||||
|
||||
const handleFeeSelection = (feeType: NetworkTransactionFeeType) => {
|
||||
if (feeType === NetworkTransactionFeeType.CUSTOM) {
|
||||
setSelectedFeeType(feeType);
|
||||
return;
|
||||
if (feeType !== NetworkTransactionFeeType.CUSTOM) {
|
||||
Keyboard.dismiss();
|
||||
}
|
||||
|
||||
Keyboard.dismiss();
|
||||
if (networkFees) {
|
||||
let selectedFee: number;
|
||||
switch (feeType) {
|
||||
case NetworkTransactionFeeType.FAST:
|
||||
onFeeSelected(networkFees.fastestFee);
|
||||
selectedFee = networkFees.fastestFee;
|
||||
break;
|
||||
case NetworkTransactionFeeType.MEDIUM:
|
||||
onFeeSelected(networkFees.mediumFee);
|
||||
selectedFee = networkFees.mediumFee;
|
||||
break;
|
||||
case NetworkTransactionFeeType.SLOW:
|
||||
onFeeSelected(networkFees.slowFee);
|
||||
selectedFee = networkFees.slowFee;
|
||||
break;
|
||||
case NetworkTransactionFeeType.CUSTOM:
|
||||
selectedFee = Number(customFeeValue);
|
||||
break;
|
||||
}
|
||||
|
||||
onFeeSelected(selectedFee);
|
||||
setSelectedFeeType(feeType);
|
||||
}
|
||||
};
|
||||
@ -93,8 +94,7 @@ const ReplaceFeeSuggestions: React.FC<ReplaceFeeSuggestionsProps> = ({ onFeeSele
|
||||
const handleCustomFeeChange = (customFee: string) => {
|
||||
const sanitizedFee = customFee.replace(/[^0-9]/g, '');
|
||||
setCustomFeeValue(sanitizedFee);
|
||||
onFeeSelected(Number(sanitizedFee));
|
||||
setSelectedFeeType(NetworkTransactionFeeType.CUSTOM);
|
||||
handleFeeSelection(NetworkTransactionFeeType.CUSTOM);
|
||||
};
|
||||
|
||||
return (
|
||||
@ -156,10 +156,7 @@ const ReplaceFeeSuggestions: React.FC<ReplaceFeeSuggestionsProps> = ({ onFeeSele
|
||||
ref={customTextInput}
|
||||
maxLength={9}
|
||||
style={[styles.customFeeInput, stylesHook.customFeeInput]}
|
||||
onFocus={() => {
|
||||
setSelectedFeeType(NetworkTransactionFeeType.CUSTOM);
|
||||
onFeeSelected(Number(customFeeValue));
|
||||
}}
|
||||
onFocus={() => handleCustomFeeChange(customFeeValue)}
|
||||
placeholder={loc.send.fee_satvbyte}
|
||||
placeholderTextColor="#81868e"
|
||||
inputAccessoryViewID={DismissKeyboardInputAccessoryViewID}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React, { useMemo, useCallback } from 'react';
|
||||
import { TouchableOpacity, Text, StyleSheet, View, useWindowDimensions } from 'react-native';
|
||||
import { TouchableOpacity, Text, StyleSheet, View } from 'react-native';
|
||||
import { useStorage } from '../hooks/context/useStorage';
|
||||
import loc, { formatBalanceWithoutSuffix } from '../loc';
|
||||
import { BitcoinUnit } from '../models/bitcoinUnits';
|
||||
@ -22,7 +22,6 @@ const TotalWalletsBalance: React.FC = React.memo(() => {
|
||||
setTotalBalancePreferredUnitStorage,
|
||||
} = useSettings();
|
||||
const { colors } = useTheme();
|
||||
const { fontScale } = useWindowDimensions();
|
||||
|
||||
const totalBalanceFormatted = useMemo(() => {
|
||||
const totalBalance = wallets.reduce((prev, curr) => {
|
||||
@ -32,22 +31,6 @@ const TotalWalletsBalance: React.FC = React.memo(() => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [wallets, totalBalancePreferredUnit, preferredFiatCurrency]);
|
||||
|
||||
const scaledStyles = useMemo(
|
||||
() => ({
|
||||
container: {
|
||||
paddingVertical: Math.round(8 * fontScale),
|
||||
},
|
||||
label: {
|
||||
lineHeight: Math.round(18 * fontScale),
|
||||
marginBottom: Math.round(2 * fontScale),
|
||||
},
|
||||
balance: {
|
||||
lineHeight: Math.round(38 * Math.max(1, fontScale)),
|
||||
},
|
||||
}),
|
||||
[fontScale],
|
||||
);
|
||||
|
||||
const toolTipActions = useMemo(
|
||||
() => [
|
||||
{
|
||||
@ -109,20 +92,13 @@ const TotalWalletsBalance: React.FC = React.memo(() => {
|
||||
|
||||
return (
|
||||
<ToolTipMenu actions={toolTipActions} onPressMenuItem={onPressMenuItem} shouldOpenOnLongPress style={styles.menuContainer}>
|
||||
<View style={[styles.container, scaledStyles.container]}>
|
||||
<Text style={[styles.label, scaledStyles.label]} numberOfLines={1} adjustsFontSizeToFit minimumFontScale={0.8}>
|
||||
{loc.wallets.total_balance}
|
||||
</Text>
|
||||
<TouchableOpacity onPress={handleBalanceOnPress} style={styles.balanceTouchable}>
|
||||
<Text
|
||||
style={[styles.balance, scaledStyles.balance, { color: colors.foregroundColor }]}
|
||||
numberOfLines={1}
|
||||
adjustsFontSizeToFit
|
||||
minimumFontScale={0.55}
|
||||
>
|
||||
{totalBalanceFormatted}
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.label}>{loc.wallets.total_balance}</Text>
|
||||
<TouchableOpacity onPress={handleBalanceOnPress}>
|
||||
<Text style={[styles.balance, { color: colors.foregroundColor }]}>
|
||||
{totalBalanceFormatted}{' '}
|
||||
{totalBalancePreferredUnit !== BitcoinUnit.LOCAL_CURRENCY && (
|
||||
<Text style={[styles.currency, { color: colors.foregroundColor }]}>{` ${totalBalancePreferredUnit}`}</Text>
|
||||
<Text style={[styles.currency, { color: colors.foregroundColor }]}>{totalBalancePreferredUnit}</Text>
|
||||
)}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
@ -140,11 +116,6 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'flex-start',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
width: '100%',
|
||||
},
|
||||
balanceTouchable: {
|
||||
alignSelf: 'stretch',
|
||||
width: '100%',
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
@ -154,7 +125,6 @@ const styles = StyleSheet.create({
|
||||
balance: {
|
||||
fontSize: 32,
|
||||
fontWeight: 'bold',
|
||||
lineHeight: 38,
|
||||
},
|
||||
currency: {
|
||||
fontSize: 18,
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import React, { memo, useCallback, useMemo, useRef } from 'react';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import Clipboard from '@react-native-clipboard/clipboard';
|
||||
import { Animated, Easing, Linking, Pressable, Text, TextStyle, ViewStyle, StyleSheet, View, useWindowDimensions } from 'react-native';
|
||||
import { Animated, Easing, Linking, Pressable, Text, TextStyle, ViewStyle, StyleSheet, View } from 'react-native';
|
||||
import Lnurl from '../class/lnurl';
|
||||
import { LightningArkWallet } from '../class/wallets/lightning-ark-wallet';
|
||||
import { LightningTransaction, Transaction } from '../class/wallets/types';
|
||||
@ -29,6 +29,9 @@ import { uint8ArrayToHex } from '../blue_modules/uint8array-extras';
|
||||
import ListItem from './ListItem';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
dateLine: {
|
||||
fontSize: 13,
|
||||
},
|
||||
fullWidthButton: {
|
||||
width: '100%',
|
||||
alignSelf: 'stretch',
|
||||
@ -130,7 +133,6 @@ const TransactionListItemComponent: React.FC<TransactionListItemProps> = ({
|
||||
const { txMetadata, counterpartyMetadata, wallets } = useStorage();
|
||||
const { language, selectedBlockExplorer } = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { fontScale } = useWindowDimensions();
|
||||
const containerStyle = useMemo(
|
||||
() => ({
|
||||
backgroundColor: colors.background,
|
||||
@ -246,7 +248,6 @@ const TransactionListItemComponent: React.FC<TransactionListItemProps> = ({
|
||||
color,
|
||||
fontSize: 14,
|
||||
fontWeight: '600' as TextStyle['fontWeight'],
|
||||
lineHeight: Math.round(20 * fontScale),
|
||||
textAlign: 'right',
|
||||
paddingRight: insets.right,
|
||||
paddingLeft: insets.left,
|
||||
@ -261,7 +262,6 @@ const TransactionListItemComponent: React.FC<TransactionListItemProps> = ({
|
||||
item.ispaid,
|
||||
insets.right,
|
||||
insets.left,
|
||||
fontScale,
|
||||
]);
|
||||
|
||||
const determineTransactionTypeAndAvatar = () => {
|
||||
@ -549,7 +549,7 @@ const TransactionListItemComponent: React.FC<TransactionListItemProps> = ({
|
||||
<ListItem
|
||||
leftAvatar={avatar}
|
||||
title={listTitle}
|
||||
subtitle={dateLine}
|
||||
subtitle={<Text style={styles.dateLine}>{dateLine}</Text>}
|
||||
chevron={false}
|
||||
rightTitle={rowTitle}
|
||||
rightTitleStyle={rowTitleStyle}
|
||||
|
||||
@ -254,7 +254,6 @@ const styles = StyleSheet.create({
|
||||
position: 'relative',
|
||||
},
|
||||
contentContainer: {
|
||||
flex: 1,
|
||||
paddingTop: WALLET_LABEL_TOP_GAP,
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: HERO_BOTTOM_PADDING,
|
||||
|
||||
@ -30,7 +30,6 @@ import WalletGradient from '../class/wallet-gradient';
|
||||
import { useSizeClass, SizeClass } from '../blue_modules/sizeClass';
|
||||
import loc, { formatBalance, transactionTimeToReadable } from '../loc';
|
||||
import { BlurredBalanceView } from './BlurredBalanceView';
|
||||
import { withAlpha } from './color';
|
||||
import { useTheme } from './themes';
|
||||
import { Transaction, TWallet } from '../class/wallets/types';
|
||||
import { BlueSpacing10 } from './BlueSpacing';
|
||||
@ -38,30 +37,6 @@ import { useLocale } from '@react-navigation/native';
|
||||
|
||||
export const WALLET_CAROUSEL_HEADER_WIDTH = 16;
|
||||
|
||||
/** Base card body height at default Dynamic Type — grows with larger Dynamic Type, never shrinks below default. */
|
||||
export const WALLET_CARD_BASE_MIN_HEIGHT = 164;
|
||||
/** Top inset above wallet cards in the horizontal home carousel. */
|
||||
export const WALLET_CAROUSEL_PADDING_TOP = 12;
|
||||
/** Bottom inset so iOS card shadows (offset 4 + radius 8) are not clipped by the list row. */
|
||||
export const WALLET_CAROUSEL_PADDING_BOTTOM = 20;
|
||||
|
||||
/** Scale layout metrics up for accessibility sizes; keep the design default when fontScale ≤ 1. */
|
||||
const scaleLayoutUp = (base: number, fontScale: number): number => Math.round(base * Math.max(1, fontScale));
|
||||
|
||||
export const getWalletCardMinHeight = (fontScale = 1): number => scaleLayoutUp(WALLET_CARD_BASE_MIN_HEIGHT, fontScale);
|
||||
|
||||
export const getWalletCarouselHeight = (fontScale = 1): number =>
|
||||
scaleLayoutUp(WALLET_CAROUSEL_PADDING_TOP, fontScale) +
|
||||
getWalletCardMinHeight(fontScale) +
|
||||
scaleLayoutUp(WALLET_CAROUSEL_PADDING_BOTTOM, fontScale);
|
||||
|
||||
/** Default carousel row height at `fontScale` 1 — prefer `getWalletCarouselHeight(fontScale)` when layout depends on Dynamic Type. */
|
||||
export const WALLET_CAROUSEL_HEIGHT = getWalletCarouselHeight(1);
|
||||
|
||||
/** Vertical gap between the wallet title/balance block and the latest-tx footer on carousel cards. */
|
||||
const WALLET_CARD_SECTION_GAP = 12;
|
||||
const WALLET_CARD_TEXT_OPACITY = 0.85;
|
||||
|
||||
export const getWalletCarouselItemWidth = (screenWidth: number) => Math.round(screenWidth * 0.82 > 375 ? 375 : screenWidth * 0.82);
|
||||
|
||||
interface NewWalletPanelProps {
|
||||
@ -185,28 +160,23 @@ const iStyles = StyleSheet.create({
|
||||
borderRadius: 12,
|
||||
minHeight: 164,
|
||||
overflow: 'hidden',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
gradCompact: {
|
||||
borderRadius: 10,
|
||||
minHeight: 132,
|
||||
overflow: 'hidden',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
gradContent: {
|
||||
padding: 15,
|
||||
width: '100%',
|
||||
},
|
||||
gradContentCompact: {
|
||||
padding: 12,
|
||||
},
|
||||
balanceContainer: {
|
||||
minHeight: 40,
|
||||
justifyContent: 'center',
|
||||
height: 40,
|
||||
},
|
||||
balanceContainerCompact: {
|
||||
minHeight: 32,
|
||||
justifyContent: 'center',
|
||||
height: 32,
|
||||
},
|
||||
image: {
|
||||
width: 99,
|
||||
@ -219,6 +189,9 @@ const iStyles = StyleSheet.create({
|
||||
width: 78,
|
||||
height: 74,
|
||||
},
|
||||
br: {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
label: {
|
||||
backgroundColor: 'transparent',
|
||||
fontSize: 19,
|
||||
@ -233,6 +206,7 @@ const iStyles = StyleSheet.create({
|
||||
},
|
||||
balanceCompact: {
|
||||
fontSize: 28,
|
||||
lineHeight: 34,
|
||||
},
|
||||
latestTx: {
|
||||
backgroundColor: 'transparent',
|
||||
@ -308,32 +282,11 @@ export const WalletCarouselItem: React.FC<WalletCarouselItemProps> = React.memo(
|
||||
const balanceOpacity = useSharedValue(1);
|
||||
const balanceTranslateY = useSharedValue(0);
|
||||
const { colors } = useTheme();
|
||||
const { width, fontScale } = useWindowDimensions();
|
||||
const { width } = useWindowDimensions();
|
||||
const itemWidth = getWalletCarouselItemWidth(width);
|
||||
const { sizeClass } = useSizeClass();
|
||||
const isCompact = sizeVariant === 'compact';
|
||||
const { direction } = useLocale();
|
||||
const scaledCardStyles = useMemo(
|
||||
() => ({
|
||||
grad: { minHeight: getWalletCardMinHeight(fontScale) },
|
||||
gradContent: { padding: scaleLayoutUp(15, fontScale) },
|
||||
balanceContainer: { minHeight: scaleLayoutUp(40, fontScale) },
|
||||
textSpacer: { height: scaleLayoutUp(WALLET_CARD_SECTION_GAP, fontScale) },
|
||||
label: { lineHeight: scaleLayoutUp(24, fontScale) },
|
||||
balance: { lineHeight: scaleLayoutUp(38, fontScale) },
|
||||
balanceCompact: { lineHeight: scaleLayoutUp(30, fontScale) },
|
||||
latestTx: { lineHeight: scaleLayoutUp(18, fontScale) },
|
||||
latestTxTime: { lineHeight: scaleLayoutUp(22, fontScale) },
|
||||
}),
|
||||
[fontScale],
|
||||
);
|
||||
const cardTextStyle = useMemo(
|
||||
() => ({
|
||||
color: withAlpha(colors.inverseForegroundColor, WALLET_CARD_TEXT_OPACITY),
|
||||
writingDirection: direction,
|
||||
}),
|
||||
[colors.inverseForegroundColor, direction],
|
||||
);
|
||||
const previousBalance = useRef<string | undefined>(undefined);
|
||||
const balance = !hideBalance && formatBalance(Number(item.getBalance()), item.getPreferredBalanceUnit(), true);
|
||||
const safeBalance = balance || undefined;
|
||||
@ -478,23 +431,23 @@ export const WalletCarouselItem: React.FC<WalletCarouselItemProps> = React.memo(
|
||||
{ backgroundColor: colors.background, shadowColor: colors.shadowColor },
|
||||
]}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={WalletGradient.gradientsFor(item.type)}
|
||||
style={[iStyles.grad, isCompact && iStyles.gradCompact, scaledCardStyles.grad]}
|
||||
>
|
||||
<LinearGradient colors={WalletGradient.gradientsFor(item.type)} style={[iStyles.grad, isCompact && iStyles.gradCompact]}>
|
||||
<ImageBackground source={image} style={[iStyles.image, isCompact && iStyles.imageCompact]} />
|
||||
<View style={[iStyles.gradContent, isCompact && iStyles.gradContentCompact, !isCompact && scaledCardStyles.gradContent]}>
|
||||
<View style={[iStyles.gradContent, isCompact && iStyles.gradContentCompact]}>
|
||||
<Text style={iStyles.br} />
|
||||
{!isPlaceHolder && (
|
||||
<>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={[iStyles.label, isCompact && iStyles.labelCompact, scaledCardStyles.label, cardTextStyle]}
|
||||
style={[
|
||||
iStyles.label,
|
||||
isCompact && iStyles.labelCompact,
|
||||
{ color: colors.inverseForegroundColor, writingDirection: direction },
|
||||
]}
|
||||
>
|
||||
{renderHighlightedText ? renderHighlightedText(walletLabel, searchQuery || '') : walletLabel}
|
||||
</Text>
|
||||
<View
|
||||
style={[iStyles.balanceContainer, isCompact && iStyles.balanceContainerCompact, scaledCardStyles.balanceContainer]}
|
||||
>
|
||||
<View style={[iStyles.balanceContainer, isCompact && iStyles.balanceContainerCompact]}>
|
||||
{hideBalance ? (
|
||||
<>
|
||||
<BlueSpacing10 />
|
||||
@ -504,13 +457,11 @@ export const WalletCarouselItem: React.FC<WalletCarouselItemProps> = React.memo(
|
||||
<Animated.Text
|
||||
numberOfLines={1}
|
||||
adjustsFontSizeToFit
|
||||
minimumFontScale={0.55}
|
||||
key={`${balance}`} // force component recreation on balance change. To fix right-to-left languages, like Farsi
|
||||
style={[
|
||||
iStyles.balance,
|
||||
isCompact && iStyles.balanceCompact,
|
||||
isCompact ? scaledCardStyles.balanceCompact : scaledCardStyles.balance,
|
||||
cardTextStyle,
|
||||
{ color: colors.inverseForegroundColor, writingDirection: direction },
|
||||
animatedBalanceStyle,
|
||||
]}
|
||||
>
|
||||
@ -518,20 +469,24 @@ export const WalletCarouselItem: React.FC<WalletCarouselItemProps> = React.memo(
|
||||
</Animated.Text>
|
||||
)}
|
||||
</View>
|
||||
<View style={scaledCardStyles.textSpacer} />
|
||||
<Text style={iStyles.br} />
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
adjustsFontSizeToFit
|
||||
minimumFontScale={0.8}
|
||||
style={[iStyles.latestTx, isCompact && iStyles.latestTxCompact, scaledCardStyles.latestTx, cardTextStyle]}
|
||||
style={[
|
||||
iStyles.latestTx,
|
||||
isCompact && iStyles.latestTxCompact,
|
||||
{ color: colors.inverseForegroundColor, writingDirection: direction },
|
||||
]}
|
||||
>
|
||||
{loc.wallets.list_latest_transaction}
|
||||
</Text>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
adjustsFontSizeToFit
|
||||
minimumFontScale={0.8}
|
||||
style={[iStyles.latestTxTime, isCompact && iStyles.latestTxTimeCompact, scaledCardStyles.latestTxTime, cardTextStyle]}
|
||||
style={[
|
||||
iStyles.latestTxTime,
|
||||
isCompact && iStyles.latestTxTimeCompact,
|
||||
{ color: colors.inverseForegroundColor, writingDirection: direction },
|
||||
]}
|
||||
>
|
||||
{latestTransactionText}
|
||||
</Text>
|
||||
@ -586,7 +541,7 @@ const WalletsCarousel = forwardRef<CarouselListRefType, WalletsCarouselProps>((p
|
||||
animateChanges = false,
|
||||
} = props;
|
||||
|
||||
const { width, fontScale } = useWindowDimensions();
|
||||
const { width } = useWindowDimensions();
|
||||
const itemWidth = React.useMemo(() => getWalletCarouselItemWidth(width), [width]);
|
||||
const snapInterval = React.useMemo(() => itemWidth, [itemWidth]);
|
||||
const snapOffsets = React.useMemo(() => {
|
||||
@ -695,7 +650,7 @@ const WalletsCarousel = forwardRef<CarouselListRefType, WalletsCarouselProps>((p
|
||||
console.warn('[WalletsCarousel] Error scrolling to wallet:', error);
|
||||
// Fallback: try scrolling to offset
|
||||
// Use different measurement based on orientation
|
||||
const itemSize = horizontal ? itemWidth : WALLET_CAROUSEL_HEIGHT;
|
||||
const itemSize = horizontal ? itemWidth : 195; // 195 is the approximate height of wallet card
|
||||
flatListRef.current.scrollToOffset({
|
||||
offset: itemSize * walletIndex,
|
||||
animated,
|
||||
@ -817,7 +772,7 @@ const WalletsCarousel = forwardRef<CarouselListRefType, WalletsCarouselProps>((p
|
||||
|
||||
const keyExtractor = useCallback((item: TWallet, index: number) => (item?.getID ? item.getID() : index.toString()), []);
|
||||
|
||||
const sliderHeight = getWalletCarouselHeight(fontScale);
|
||||
const sliderHeight = 195;
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@ -900,8 +855,7 @@ const WalletsCarousel = forwardRef<CarouselListRefType, WalletsCarouselProps>((p
|
||||
|
||||
const cStyles = StyleSheet.create({
|
||||
content: {
|
||||
paddingTop: scaleLayoutUp(WALLET_CAROUSEL_PADDING_TOP, fontScale),
|
||||
paddingBottom: scaleLayoutUp(WALLET_CAROUSEL_PADDING_BOTTOM, fontScale),
|
||||
paddingTop: 16,
|
||||
},
|
||||
contentLargeScreen: {
|
||||
paddingHorizontal: sizeClass === SizeClass.Large ? 16 : 12,
|
||||
@ -932,7 +886,7 @@ const WalletsCarousel = forwardRef<CarouselListRefType, WalletsCarouselProps>((p
|
||||
automaticallyAdjustContentInsets
|
||||
automaticallyAdjustKeyboardInsets
|
||||
automaticallyAdjustsScrollIndicatorInsets
|
||||
style={{ minHeight: sliderHeight }}
|
||||
style={{ minHeight: sliderHeight + 12 }}
|
||||
onScrollToIndexFailed={onScrollToIndexFailed}
|
||||
ListFooterComponent={onNewWalletPress ? <NewWalletPanel onPress={onNewWalletPress} /> : null}
|
||||
{...props}
|
||||
|
||||
@ -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.",
|
||||
|
||||
32
package-lock.json
generated
32
package-lock.json
generated
@ -17,8 +17,7 @@
|
||||
"@bugsnag/source-maps": "2.3.3",
|
||||
"@keystonehq/bc-ur-registry": "0.7.1",
|
||||
"@ngraveio/bc-ur": "1.1.13",
|
||||
"@noble/ciphers": "1.3.0",
|
||||
"@noble/hashes": "1.8.0",
|
||||
"@noble/hashes": "1.3.3",
|
||||
"@noble/secp256k1": "3.1.0",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-native-clipboard/clipboard": "1.16.3",
|
||||
@ -58,6 +57,7 @@
|
||||
"buffer": "6.0.3",
|
||||
"coinselect": "github:BlueWallet/coinselect#35f8038",
|
||||
"crypto-browserify": "3.12.1",
|
||||
"crypto-js": "4.2.0",
|
||||
"dayjs": "1.11.21",
|
||||
"detox": "20.51.3",
|
||||
"ecpair": "3.0.1",
|
||||
@ -126,6 +126,7 @@
|
||||
"@types/bip38": "^3.1.2",
|
||||
"@types/bs58check": "^2.1.0",
|
||||
"@types/create-hash": "^1.2.2",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/jest": "^29.5.13",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-test-renderer": "^19.1.0",
|
||||
@ -3496,18 +3497,6 @@
|
||||
"eslint-scope": "5.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/ciphers": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
|
||||
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/curves": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz",
|
||||
@ -3536,12 +3525,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
||||
"version": "1.3.3",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
@ -5276,6 +5263,11 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/crypto-js": {
|
||||
"version": "4.2.2",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/graceful-fs": {
|
||||
"version": "4.1.9",
|
||||
"dev": true,
|
||||
@ -7963,6 +7955,10 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/crypto-js": {
|
||||
"version": "4.2.0",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/css-select": {
|
||||
"version": "5.1.0",
|
||||
"license": "BSD-2-Clause",
|
||||
|
||||
@ -27,6 +27,7 @@
|
||||
"@types/bip38": "^3.1.2",
|
||||
"@types/bs58check": "^2.1.0",
|
||||
"@types/create-hash": "^1.2.2",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/jest": "^29.5.13",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-test-renderer": "^19.1.0",
|
||||
@ -99,8 +100,7 @@
|
||||
"@bugsnag/source-maps": "2.3.3",
|
||||
"@keystonehq/bc-ur-registry": "0.7.1",
|
||||
"@ngraveio/bc-ur": "1.1.13",
|
||||
"@noble/ciphers": "1.3.0",
|
||||
"@noble/hashes": "1.8.0",
|
||||
"@noble/hashes": "1.3.3",
|
||||
"@noble/secp256k1": "3.1.0",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-native-clipboard/clipboard": "1.16.3",
|
||||
@ -140,6 +140,7 @@
|
||||
"buffer": "6.0.3",
|
||||
"coinselect": "github:BlueWallet/coinselect#35f8038",
|
||||
"crypto-browserify": "3.12.1",
|
||||
"crypto-js": "4.2.0",
|
||||
"dayjs": "1.11.21",
|
||||
"detox": "20.51.3",
|
||||
"ecpair": "3.0.1",
|
||||
|
||||
@ -2,9 +2,10 @@ import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState }
|
||||
import { ActivityIndicator, BackHandler, Linking, StyleSheet, Text, TouchableOpacity, useWindowDimensions, View } from 'react-native';
|
||||
import { sha256 } from '@noble/hashes/sha256';
|
||||
import { RouteProp, useRoute } from '@react-navigation/native';
|
||||
import { NativeStackNavigationOptions, NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
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';
|
||||
@ -63,10 +65,6 @@ enum ButtonStatus {
|
||||
type RouteProps = RouteProp<DetailViewStackParamList, 'TransactionStatus'>;
|
||||
type NavigationProps = NativeStackNavigationProp<DetailViewStackParamList, 'TransactionStatus'>;
|
||||
|
||||
type TransactionStatusHeaderOptions = NativeStackNavigationOptions & {
|
||||
headerTitleContainerStyle?: { flex: number; maxWidth: number };
|
||||
};
|
||||
|
||||
enum ActionType {
|
||||
SetCPFPPossible,
|
||||
SetRBFBumpFeePossible,
|
||||
@ -140,12 +138,8 @@ type TransactionDetailHeaderTitleProps = {
|
||||
|
||||
const TransactionDetailHeaderTitle: React.FC<TransactionDetailHeaderTitleProps> = ({ direction, date, directionStyle, dateStyle }) => (
|
||||
<View style={styles.headerTitleContainer}>
|
||||
<BlueText style={directionStyle} numberOfLines={1} adjustsFontSizeToFit minimumFontScale={0.8}>
|
||||
{direction}
|
||||
</BlueText>
|
||||
<BlueText style={dateStyle} numberOfLines={2} adjustsFontSizeToFit minimumFontScale={0.8}>
|
||||
{date}
|
||||
</BlueText>
|
||||
<BlueText style={directionStyle}>{direction}</BlueText>
|
||||
<BlueText style={dateStyle}>{date}</BlueText>
|
||||
</View>
|
||||
);
|
||||
|
||||
@ -161,61 +155,25 @@ const TransactionStatus: React.FC = () => {
|
||||
const subscribedWallet = useWalletSubscribe(walletID);
|
||||
const { navigate, goBack, setOptions } = useExtendedNavigation<NavigationProps>();
|
||||
const { colors } = useTheme();
|
||||
const { width: windowWidth, fontScale } = useWindowDimensions();
|
||||
const { width: windowWidth } = useWindowDimensions();
|
||||
const { selectedBlockExplorer } = useSettings();
|
||||
const fetchTxInterval = useRef<NodeJS.Timeout | undefined>(undefined);
|
||||
|
||||
const scaledStyles = useMemo(() => {
|
||||
const valueLineHeight = Math.round(48 * fontScale);
|
||||
const valuePaddingTop = Math.round(8 * fontScale);
|
||||
|
||||
return {
|
||||
value: {
|
||||
lineHeight: valueLineHeight,
|
||||
paddingTop: valuePaddingTop,
|
||||
minHeight: valueLineHeight + valuePaddingTop,
|
||||
},
|
||||
localCurrency: {
|
||||
lineHeight: Math.round(20 * fontScale),
|
||||
marginTop: Math.round(6 * fontScale),
|
||||
},
|
||||
headerTitleDirection: {
|
||||
lineHeight: Math.round(22 * fontScale),
|
||||
},
|
||||
headerTitleDate: {
|
||||
lineHeight: Math.round(18 * fontScale),
|
||||
},
|
||||
stateLabel: {
|
||||
lineHeight: Math.round(22 * fontScale),
|
||||
},
|
||||
stateValue: {
|
||||
lineHeight: Math.round(18 * fontScale),
|
||||
},
|
||||
advancedHeader: {
|
||||
minHeight: Math.round(44 * fontScale),
|
||||
},
|
||||
explorerButton: {
|
||||
paddingVertical: Math.round(6 * fontScale),
|
||||
paddingHorizontal: Math.round(12 * fontScale),
|
||||
},
|
||||
addButton: {
|
||||
paddingVertical: Math.round(4 * fontScale),
|
||||
paddingHorizontal: Math.round(12 * fontScale),
|
||||
},
|
||||
detailRow: {
|
||||
minHeight: Math.round(24 * fontScale),
|
||||
paddingVertical: Math.round(12 * fontScale),
|
||||
},
|
||||
sectionTitle: {
|
||||
paddingVertical: Math.round(16 * fontScale),
|
||||
},
|
||||
};
|
||||
}, [fontScale]);
|
||||
|
||||
// Explicit width for To/ID text so Android StaticLayout can apply ellipsis (flex alone often fails on Android)
|
||||
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);
|
||||
@ -893,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') : '-';
|
||||
@ -976,20 +942,15 @@ const TransactionStatus: React.FC = () => {
|
||||
<TransactionDetailHeaderTitle
|
||||
direction={transactionDirection}
|
||||
date={transactionDate}
|
||||
directionStyle={[styles.headerTitleDirection, stylesHook.headerTitleDirection, scaledStyles.headerTitleDirection]}
|
||||
dateStyle={[styles.headerTitleDate, stylesHook.titleDate, scaledStyles.headerTitleDate]}
|
||||
directionStyle={[styles.headerTitleDirection, stylesHook.headerTitleDirection]}
|
||||
dateStyle={[styles.headerTitleDate, stylesHook.titleDate]}
|
||||
/>
|
||||
),
|
||||
headerTitleAlign: 'left',
|
||||
headerTitleContainerStyle: {
|
||||
flex: 1,
|
||||
maxWidth: Math.max(0, windowWidth - 96),
|
||||
},
|
||||
} as TransactionStatusHeaderOptions);
|
||||
});
|
||||
}
|
||||
// stylesHook is derived from colors; omitting to avoid unnecessary effect runs
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tx, transactionDirection, transactionDate, setOptions, colors, windowWidth, scaledStyles]);
|
||||
}, [tx, transactionDirection, transactionDate, setOptions, colors]);
|
||||
|
||||
if (loadingError) {
|
||||
return (
|
||||
@ -1022,20 +983,15 @@ const TransactionStatus: React.FC = () => {
|
||||
{/* Value Section */}
|
||||
<View style={styles.valueCard}>
|
||||
<View style={styles.valueContent}>
|
||||
<Text
|
||||
style={[styles.value, stylesHook.value, scaledStyles.value, styles.valueFullWidth]}
|
||||
selectable
|
||||
numberOfLines={1}
|
||||
adjustsFontSizeToFit
|
||||
minimumFontScale={0.55}
|
||||
>
|
||||
<Text style={[styles.value, stylesHook.value]} selectable numberOfLines={1} adjustsFontSizeToFit minimumFontScale={0.55}>
|
||||
{txValue !== null ? formatBalanceWithoutSuffix(txValue, preferredBalanceUnit, true) : '-'}
|
||||
{` `}
|
||||
{preferredBalanceUnit !== BitcoinUnit.LOCAL_CURRENCY && (
|
||||
<Text style={[styles.valueUnit, stylesHook.valueUnit]}>{` ${preferredBalanceUnit}`}</Text>
|
||||
<Text style={[styles.valueUnit, stylesHook.valueUnit]}>{preferredBalanceUnit}</Text>
|
||||
)}
|
||||
</Text>
|
||||
{txValue !== null && (
|
||||
<Text style={[styles.localCurrency, stylesHook.localCurrency, scaledStyles.localCurrency]}>
|
||||
<Text style={[styles.localCurrency, stylesHook.localCurrency]}>
|
||||
{preferredBalanceUnit === BitcoinUnit.LOCAL_CURRENCY
|
||||
? `${formatBalanceWithoutSuffix(Math.abs(txValue), BitcoinUnit.BTC, true)} ${BitcoinUnit.BTC}`
|
||||
: satoshiToLocalCurrency(Math.abs(txValue))}
|
||||
@ -1045,7 +1001,7 @@ const TransactionStatus: React.FC = () => {
|
||||
</View>
|
||||
|
||||
{/* State Section */}
|
||||
<View
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.stateCard,
|
||||
isPending
|
||||
@ -1053,6 +1009,7 @@ const TransactionStatus: React.FC = () => {
|
||||
: txValue !== null && txValue < 0
|
||||
? stylesHook.stateCardSent
|
||||
: stylesHook.stateCardReceived,
|
||||
stateCardAnimatedStyle,
|
||||
]}
|
||||
>
|
||||
<View style={styles.stateSection}>
|
||||
@ -1061,10 +1018,8 @@ const TransactionStatus: React.FC = () => {
|
||||
<View style={styles.stateIndicator}>
|
||||
<TransactionPendingIcon />
|
||||
<View style={styles.stateLabelContainer}>
|
||||
<BlueText style={[styles.stateLabel, stylesHook.stateLabelPending, scaledStyles.stateLabel]}>
|
||||
{loc.transactions.pending}
|
||||
</BlueText>
|
||||
<BlueText style={[styles.stateValue, stylesHook.stateValuePending, styles.stateValueInline, scaledStyles.stateValue]}>
|
||||
<BlueText style={[styles.stateLabel, stylesHook.stateLabelPending]}>{loc.transactions.pending}</BlueText>
|
||||
<BlueText style={[styles.stateValue, stylesHook.stateValuePending, styles.stateValueInline]}>
|
||||
{eta || loc.transactions.details_eta_analyzing}
|
||||
</BlueText>
|
||||
</View>
|
||||
@ -1093,40 +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, scaledStyles.stateLabel]}>
|
||||
{loc.transactions.details_sent}
|
||||
</BlueText>
|
||||
{isOnChainTx && (
|
||||
<BlueText style={[styles.stateValue, stylesHook.stateValueSent, styles.stateValueInline, scaledStyles.stateValue]}>
|
||||
{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, scaledStyles.stateLabel]}>
|
||||
{loc.transactions.details_received}
|
||||
</BlueText>
|
||||
{isOnChainTx && (
|
||||
<BlueText style={[styles.stateValue, stylesHook.stateValueReceived, styles.stateValueInline, scaledStyles.stateValue]}>
|
||||
{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 && (
|
||||
@ -1151,29 +1124,20 @@ const TransactionStatus: React.FC = () => {
|
||||
{/* Details Section */}
|
||||
<View style={[styles.detailsCard, stylesHook.detailsCard]}>
|
||||
{/* Details Title */}
|
||||
<View style={[styles.sectionTitle, styles.sectionTitleWithButton, stylesHook.sectionTitle, scaledStyles.sectionTitle]}>
|
||||
<BlueText style={[styles.sectionTitleText, stylesHook.sectionTitleText, styles.sectionTitleTextFlexible]}>
|
||||
{loc.transactions.details_section}
|
||||
</BlueText>
|
||||
<View style={[styles.sectionTitle, styles.sectionTitleWithButton, stylesHook.sectionTitle]}>
|
||||
<BlueText style={[styles.sectionTitleText, stylesHook.sectionTitleText]}>{loc.transactions.details_section}</BlueText>
|
||||
{tx?.hash && (
|
||||
<TouchableOpacity
|
||||
onPress={handleOpenBlockExplorer}
|
||||
style={[styles.explorerButton, stylesHook.explorerButton, scaledStyles.explorerButton]}
|
||||
style={[styles.explorerButton, stylesHook.explorerButton]}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<BlueText
|
||||
style={[styles.explorerButtonText, stylesHook.explorerButtonText]}
|
||||
numberOfLines={1}
|
||||
adjustsFontSizeToFit
|
||||
minimumFontScale={0.8}
|
||||
>
|
||||
{loc.transactions.details_explorer}
|
||||
</BlueText>
|
||||
<BlueText style={[styles.explorerButtonText, stylesHook.explorerButtonText]}>{loc.transactions.details_explorer}</BlueText>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
{/* Network Fee */}
|
||||
<View style={[styles.detailRow, stylesHook.detailRow, scaledStyles.detailRow]}>
|
||||
<View style={[styles.detailRow, stylesHook.detailRow]}>
|
||||
<BlueText style={[styles.detailLabel, stylesHook.detailLabel]}>{loc.transactions.details_network_fee}</BlueText>
|
||||
<View style={styles.detailValueContainer}>
|
||||
<CopyTextToClipboard
|
||||
@ -1197,7 +1161,7 @@ const TransactionStatus: React.FC = () => {
|
||||
const displayText = externalAddresses.map(shortenCounterpartyName).join(', ');
|
||||
const copyText = externalAddresses.join(', ');
|
||||
return (
|
||||
<View style={[styles.detailRow, stylesHook.detailRow, scaledStyles.detailRow]}>
|
||||
<View style={[styles.detailRow, stylesHook.detailRow]}>
|
||||
<BlueText style={[styles.detailLabel, stylesHook.detailLabel]}>{loc.transactions.details_to_address}</BlueText>
|
||||
<View style={styles.detailValueContainer}>
|
||||
<View style={styles.detailValueCopyContainer}>
|
||||
@ -1223,7 +1187,7 @@ const TransactionStatus: React.FC = () => {
|
||||
|
||||
{/* Transaction ID - display shortened so it stays on one line on Android; copy still gets full hash */}
|
||||
{tx.hash && (
|
||||
<View style={[styles.detailRow, stylesHook.detailRow, scaledStyles.detailRow]}>
|
||||
<View style={[styles.detailRow, stylesHook.detailRow]}>
|
||||
<BlueText style={[styles.detailLabel, stylesHook.detailLabel]}>{loc.transactions.details_id}</BlueText>
|
||||
<View style={styles.detailValueContainer}>
|
||||
<View style={styles.detailValueCopyContainer}>
|
||||
@ -1250,7 +1214,7 @@ const TransactionStatus: React.FC = () => {
|
||||
)}
|
||||
|
||||
{/* Note/Memo */}
|
||||
<View style={[styles.detailRow, styles.detailRowLast, stylesHook.detailRow, scaledStyles.detailRow]}>
|
||||
<View style={[styles.detailRow, styles.detailRowLast, stylesHook.detailRow]}>
|
||||
<BlueText style={[styles.detailLabel, stylesHook.detailLabel]}>{loc.transactions.details_note}</BlueText>
|
||||
<View style={styles.detailValueContainer}>
|
||||
{memo ? (
|
||||
@ -1260,19 +1224,8 @@ const TransactionStatus: React.FC = () => {
|
||||
</BlueText>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
onPress={handleNotePress}
|
||||
style={[styles.addButton, stylesHook.addButton, scaledStyles.addButton]}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<BlueText
|
||||
style={[styles.addButtonText, stylesHook.addButtonText]}
|
||||
numberOfLines={1}
|
||||
adjustsFontSizeToFit
|
||||
minimumFontScale={0.8}
|
||||
>
|
||||
{loc.transactions.details_add_note}
|
||||
</BlueText>
|
||||
<TouchableOpacity onPress={handleNotePress} style={[styles.addButton, stylesHook.addButton]} activeOpacity={0.7}>
|
||||
<BlueText style={[styles.addButtonText, stylesHook.addButtonText]}>{loc.transactions.details_add_note}</BlueText>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
@ -1282,14 +1235,15 @@ const TransactionStatus: React.FC = () => {
|
||||
{/* Advanced Section */}
|
||||
<View style={[styles.detailsCard, stylesHook.detailsCard]}>
|
||||
<TouchableOpacity
|
||||
onPress={() => setIsAdvancedExpanded(!isAdvancedExpanded)}
|
||||
style={[styles.advancedHeader, stylesHook.advancedHeader, scaledStyles.advancedHeader]}
|
||||
onPress={() => {
|
||||
triggerHapticFeedback(HapticFeedbackTypes.ImpactLight);
|
||||
setIsAdvancedExpanded(!isAdvancedExpanded);
|
||||
}}
|
||||
style={[styles.advancedHeader, stylesHook.advancedHeader]}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<View style={[styles.sectionTitle, stylesHook.sectionTitle, styles.sectionTitleRow, scaledStyles.sectionTitle]}>
|
||||
<BlueText style={[styles.sectionTitleText, stylesHook.sectionTitleText, styles.sectionTitleTextFlexible]} numberOfLines={2}>
|
||||
{loc.transactions.details_advanced}
|
||||
</BlueText>
|
||||
<View style={[styles.sectionTitle, stylesHook.sectionTitle, styles.sectionTitleRow]}>
|
||||
<BlueText style={[styles.sectionTitleText, stylesHook.sectionTitleText]}>{loc.transactions.details_advanced}</BlueText>
|
||||
<Icon
|
||||
name={isAdvancedExpanded ? 'chevron-up' : 'chevron-down'}
|
||||
type="font-awesome"
|
||||
@ -1302,7 +1256,7 @@ const TransactionStatus: React.FC = () => {
|
||||
{isAdvancedExpanded && (
|
||||
<View style={[styles.advancedContent, stylesHook.advancedContent]}>
|
||||
{/* Fee Rate */}
|
||||
<View style={[styles.detailRow, stylesHook.detailRow, scaledStyles.detailRow]}>
|
||||
<View style={[styles.detailRow, stylesHook.detailRow]}>
|
||||
<BlueText style={[styles.detailLabel, stylesHook.detailLabel]}>{loc.transactions.details_fee_rate}</BlueText>
|
||||
<View style={styles.detailValueContainer}>
|
||||
<CopyTextToClipboard
|
||||
@ -1314,7 +1268,7 @@ const TransactionStatus: React.FC = () => {
|
||||
</View>
|
||||
|
||||
{/* Size */}
|
||||
<View style={[styles.detailRow, stylesHook.detailRow, scaledStyles.detailRow]}>
|
||||
<View style={[styles.detailRow, stylesHook.detailRow]}>
|
||||
<BlueText style={[styles.detailLabel, stylesHook.detailLabel]}>{loc.transactions.details_size}</BlueText>
|
||||
<View style={styles.detailValueContainer}>
|
||||
<CopyTextToClipboard
|
||||
@ -1326,7 +1280,7 @@ const TransactionStatus: React.FC = () => {
|
||||
</View>
|
||||
|
||||
{/* Virtual Size */}
|
||||
<View style={[styles.detailRow, stylesHook.detailRow, scaledStyles.detailRow]}>
|
||||
<View style={[styles.detailRow, stylesHook.detailRow]}>
|
||||
<BlueText style={[styles.detailLabel, stylesHook.detailLabel]}>{loc.transactions.details_virtual_size}</BlueText>
|
||||
<View style={styles.detailValueContainer}>
|
||||
<CopyTextToClipboard
|
||||
@ -1338,7 +1292,7 @@ const TransactionStatus: React.FC = () => {
|
||||
</View>
|
||||
|
||||
{/* Transaction Hex */}
|
||||
<View style={[styles.detailRow, stylesHook.detailRow, scaledStyles.detailRow]}>
|
||||
<View style={[styles.detailRow, stylesHook.detailRow]}>
|
||||
<BlueText style={[styles.detailLabel, stylesHook.detailLabel]}>{loc.transactions.details_tx_hex}</BlueText>
|
||||
<View style={styles.detailValueContainer}>
|
||||
{txHex ? (
|
||||
@ -1403,7 +1357,6 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'center',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
headerTitleDirection: {
|
||||
fontSize: 17,
|
||||
@ -1451,20 +1404,15 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'flex-start',
|
||||
overflow: 'visible',
|
||||
width: '100%',
|
||||
},
|
||||
value: {
|
||||
fontSize: 40,
|
||||
fontWeight: '700',
|
||||
letterSpacing: -0.5,
|
||||
lineHeight: 48,
|
||||
lineHeight: 32,
|
||||
paddingTop: 8,
|
||||
minHeight: 38,
|
||||
},
|
||||
valueFullWidth: {
|
||||
width: '100%',
|
||||
flexShrink: 1,
|
||||
},
|
||||
valueUnit: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
@ -1482,6 +1430,7 @@ const styles = StyleSheet.create({
|
||||
borderRadius: 12,
|
||||
marginHorizontal: 24,
|
||||
marginBottom: 42,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
stateSection: {
|
||||
alignItems: 'flex-start',
|
||||
@ -1489,17 +1438,23 @@ 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',
|
||||
alignItems: 'flex-start',
|
||||
marginLeft: 8,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
stateLabel: {
|
||||
fontSize: 16,
|
||||
@ -1585,23 +1540,17 @@ const styles = StyleSheet.create({
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
sectionTitleText: {
|
||||
fontSize: 17,
|
||||
fontWeight: '600',
|
||||
},
|
||||
sectionTitleTextFlexible: {
|
||||
flex: 1,
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
explorerButton: {
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 6,
|
||||
alignSelf: 'flex-end',
|
||||
flexShrink: 0,
|
||||
minWidth: 50,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
@ -1612,7 +1561,7 @@ const styles = StyleSheet.create({
|
||||
detailRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
alignItems: 'center',
|
||||
marginBottom: 0,
|
||||
minHeight: 24,
|
||||
paddingVertical: 12,
|
||||
@ -1636,8 +1585,6 @@ const styles = StyleSheet.create({
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
flex: 1,
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
lineHeight: 22,
|
||||
paddingRight: 12,
|
||||
},
|
||||
@ -1651,12 +1598,11 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
maxWidth: '100%',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'flex-end',
|
||||
flexWrap: 'nowrap',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
gap: 8,
|
||||
flexShrink: 0,
|
||||
},
|
||||
detailValueCopyContainer: {
|
||||
flex: 1,
|
||||
@ -1704,7 +1650,7 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 6,
|
||||
alignSelf: 'flex-end',
|
||||
flexShrink: 0,
|
||||
minWidth: 50,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
@ -1722,6 +1668,7 @@ const styles = StyleSheet.create({
|
||||
borderWidth: 1,
|
||||
borderTopLeftRadius: 12,
|
||||
borderTopRightRadius: 12,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
advancedContent: {
|
||||
marginTop: 0,
|
||||
|
||||
@ -33,7 +33,6 @@ import presentAlert, { AlertType } from '../../components/Alert';
|
||||
import { FButton, FContainer, FloatButtonsBottomFade } from '../../components/FloatButtons';
|
||||
import { useTheme } from '../../components/themes';
|
||||
import { TransactionListItem } from '../../components/TransactionListItem';
|
||||
import { TX_ROW_BASE_HEIGHT } from '../../components/ListItem';
|
||||
import TransactionsNavigationHeader, { actionKeys } from '../../components/TransactionsNavigationHeader';
|
||||
import { unlockWithBiometrics, useBiometrics } from '../../hooks/useBiometrics';
|
||||
import { useExtendedNavigation } from '../../hooks/useExtendedNavigation';
|
||||
@ -438,17 +437,11 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
|
||||
[name, navigate, navigation, onWalletSelect, walletID, wallets],
|
||||
);
|
||||
|
||||
const { fontScale } = useWindowDimensions();
|
||||
const txRowHeight = Math.round(TX_ROW_BASE_HEIGHT * fontScale);
|
||||
|
||||
const getItemLayout = useCallback(
|
||||
(_: any, index: number) => ({
|
||||
length: txRowHeight,
|
||||
offset: txRowHeight * index,
|
||||
index,
|
||||
}),
|
||||
[txRowHeight],
|
||||
);
|
||||
const getItemLayout = (_: any, index: number) => ({
|
||||
length: 64,
|
||||
offset: 64 * index,
|
||||
index,
|
||||
});
|
||||
|
||||
const renderItem = useCallback(
|
||||
// react/no-unused-prop-types misfires on inline arrow renderers: it reads the
|
||||
|
||||
@ -8,15 +8,10 @@ import triggerHapticFeedback, { HapticFeedbackTypes } from '../../blue_modules/h
|
||||
import DeeplinkSchemaMatch from '../../class/deeplink-schema-match';
|
||||
import { ExtendedTransaction, Transaction, TWallet } from '../../class/wallets/types';
|
||||
import presentAlert from '../../components/Alert';
|
||||
import { FButton, FContainer, FloatButtonsBottomFade, getFloatingButtonReservedHeight } from '../../components/FloatButtons';
|
||||
import { FButton, FContainer, FloatButtonsBottomFade } from '../../components/FloatButtons';
|
||||
import { useTheme } from '../../components/themes';
|
||||
import { TransactionListItem } from '../../components/TransactionListItem';
|
||||
import { TX_ROW_BASE_HEIGHT } from '../../components/ListItem';
|
||||
import WalletsCarousel, {
|
||||
getWalletCarouselItemWidth,
|
||||
CarouselListRefType,
|
||||
getWalletCarouselHeight,
|
||||
} from '../../components/WalletsCarousel';
|
||||
import WalletsCarousel, { getWalletCarouselItemWidth, CarouselListRefType } from '../../components/WalletsCarousel';
|
||||
import { useSizeClass, SizeClass } from '../../blue_modules/sizeClass';
|
||||
import loc from '../../loc';
|
||||
import ActionSheet from '../ActionSheet';
|
||||
@ -33,7 +28,6 @@ import { scanQrHelper } from '../../helpers/scan-qr';
|
||||
import { isIOS26OrHigher } from '../../components/platform';
|
||||
|
||||
const WalletsListSections = { CAROUSEL: 'CAROUSEL', TRANSACTIONS: 'TRANSACTIONS' };
|
||||
const SECTION_HEADER_BASE_HEIGHT = 56;
|
||||
|
||||
/** Electrum `ping` while the list is visible; detects mid-session drops without polling when user is elsewhere. */
|
||||
const ELECTRUM_HEALTH_POLL_WHILE_WALLETS_LIST_FOCUSED_MS = 30_000;
|
||||
@ -114,11 +108,7 @@ const WalletsList: React.FC = () => {
|
||||
const { registerTransactionsHandler, unregisterTransactionsHandler } = useMenuElements();
|
||||
const { wallets, getTransactions, refreshAllWalletTransactions } = useStorage();
|
||||
const { isTotalBalanceEnabled, isElectrumDisabled } = useSettings();
|
||||
const { width, fontScale } = useWindowDimensions();
|
||||
const carouselHeight = getWalletCarouselHeight(fontScale);
|
||||
const transactionItemHeight = Math.round(TX_ROW_BASE_HEIGHT * fontScale);
|
||||
const sectionHeaderHeight = Math.round(SECTION_HEADER_BASE_HEIGHT * fontScale);
|
||||
const floatingButtonHeight = getFloatingButtonReservedHeight(fontScale);
|
||||
const { width } = useWindowDimensions();
|
||||
const { colors, scanImage } = useTheme();
|
||||
const navigation = useExtendedNavigation<NavigationProps>();
|
||||
const isFocused = useIsFocused();
|
||||
@ -134,11 +124,9 @@ const WalletsList: React.FC = () => {
|
||||
listHeaderBack: {
|
||||
backgroundColor: colors.background,
|
||||
paddingTop: sizeClass === SizeClass.Large ? 8 : 0,
|
||||
minHeight: sectionHeaderHeight,
|
||||
},
|
||||
listHeaderText: {
|
||||
color: colors.foregroundColor,
|
||||
marginVertical: Math.round(16 * fontScale),
|
||||
},
|
||||
});
|
||||
|
||||
@ -505,9 +493,14 @@ const WalletsList: React.FC = () => {
|
||||
}, [sizeClass, dataSource]);
|
||||
|
||||
// Constants for layout calculations
|
||||
const TRANSACTION_ITEM_HEIGHT = 80;
|
||||
const CAROUSEL_HEIGHT = 195;
|
||||
const SECTION_HEADER_HEIGHT = 56; // Base height
|
||||
const LARGE_TITLE_EXTRA_HEIGHT = 20; // Additional height for large titles
|
||||
|
||||
const getSectionHeaderHeight = useCallback(() => {
|
||||
return sectionHeaderHeight + (sizeClass === SizeClass.Large ? Math.round(20 * fontScale) : 0);
|
||||
}, [sizeClass, sectionHeaderHeight, fontScale]);
|
||||
return SECTION_HEADER_HEIGHT + (sizeClass === SizeClass.Large ? LARGE_TITLE_EXTRA_HEIGHT : 0);
|
||||
}, [sizeClass]);
|
||||
|
||||
const getItemLayout = useCallback(
|
||||
(data: any, index: number) => {
|
||||
@ -516,8 +509,8 @@ const WalletsList: React.FC = () => {
|
||||
if (sizeClass === SizeClass.Large) {
|
||||
// On large screens: only transaction items, no carousel
|
||||
return {
|
||||
length: transactionItemHeight,
|
||||
offset: transactionItemHeight * index,
|
||||
length: TRANSACTION_ITEM_HEIGHT,
|
||||
offset: TRANSACTION_ITEM_HEIGHT * index,
|
||||
index,
|
||||
};
|
||||
} else {
|
||||
@ -525,7 +518,7 @@ const WalletsList: React.FC = () => {
|
||||
// First section: Carousel
|
||||
if (index === 0) {
|
||||
return {
|
||||
length: carouselHeight,
|
||||
length: CAROUSEL_HEIGHT,
|
||||
offset: 0,
|
||||
index,
|
||||
};
|
||||
@ -538,13 +531,13 @@ const WalletsList: React.FC = () => {
|
||||
// 3. Transaction items
|
||||
const transactionIndex = index - 1; // Adjust index to account for carousel
|
||||
return {
|
||||
length: transactionItemHeight,
|
||||
offset: carouselHeight + headerHeight + transactionItemHeight * transactionIndex,
|
||||
length: TRANSACTION_ITEM_HEIGHT,
|
||||
offset: CAROUSEL_HEIGHT + headerHeight + TRANSACTION_ITEM_HEIGHT * transactionIndex,
|
||||
index,
|
||||
};
|
||||
}
|
||||
},
|
||||
[sizeClass, getSectionHeaderHeight, carouselHeight, transactionItemHeight],
|
||||
[sizeClass, getSectionHeaderHeight],
|
||||
);
|
||||
|
||||
return (
|
||||
@ -557,7 +550,7 @@ const WalletsList: React.FC = () => {
|
||||
initialNumToRender={10}
|
||||
renderSectionFooter={renderSectionFooter}
|
||||
sections={sections}
|
||||
floatingButtonHeight={floatingButtonHeight}
|
||||
floatingButtonHeight={70}
|
||||
maxToRenderPerBatch={10}
|
||||
updateCellsBatchingPeriod={50}
|
||||
getItemLayout={getItemLayout}
|
||||
|
||||
@ -43,19 +43,4 @@ describe('unit - encryption', function () {
|
||||
const decrypted = c.decrypt(crypted, 'password');
|
||||
assert.deepEqual(data2decrypt, decrypted);
|
||||
});
|
||||
|
||||
it('can decrypt a ciphertext produced by the OpenSSL CLI (wire-format check)', () => {
|
||||
// Regenerate this fixture with (copy-pasteable, verified to reproduce the byte string below):
|
||||
//
|
||||
// { printf 'Salted__\x01\x02\x03\x04\x05\x06\x07\x08'; \
|
||||
// printf 'hello world this is plaintext' \
|
||||
// | openssl enc -aes-256-cbc -k mypassword -S 0102030405060708 -md md5; \
|
||||
// } | base64
|
||||
//
|
||||
// OpenSSL's `enc` only emits the `Salted__` envelope when it picks the salt itself;
|
||||
// passing `-S <hex>` suppresses the header, so we prepend it manually. Pins the
|
||||
// on-disk format against an independent reference beyond crypto-js.
|
||||
const crypted = 'U2FsdGVkX18BAgMEBQYHCMqtJuZaneiHrVN/oMPPLvFplovZbI1K+lulGJn7NAvn';
|
||||
assert.strictEqual(c.decrypt(crypted, 'mypassword'), 'hello world this is plaintext');
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,51 +0,0 @@
|
||||
import assert from 'assert';
|
||||
|
||||
import { evpBytesToKeyMd5 } from '../../blue_modules/encryption';
|
||||
import { hexToUint8Array, stringToUint8Array, uint8ArrayToHex } from '../../blue_modules/uint8array-extras';
|
||||
|
||||
describe('evpBytesToKeyMd5', () => {
|
||||
// Vectors computed against the OpenSSL EVP_BytesToKey reference algorithm
|
||||
// (MD5, 1 iteration). The KDF is purely deterministic, so a single fixed
|
||||
// (password, salt) pair pins the bytes our wallet store relies on.
|
||||
it('matches the OpenSSL CLI reference for password="mypassword"', () => {
|
||||
// openssl enc -aes-256-cbc -k mypassword -S 0102030405060708 -md md5 -p
|
||||
const out = evpBytesToKeyMd5(stringToUint8Array('mypassword'), hexToUint8Array('0102030405060708'), 48);
|
||||
assert.strictEqual(uint8ArrayToHex(out.subarray(0, 32)), '20814c3ad75ac1d26c61a8e4702b5ff4d7baaee00c595bab71592aaf45bf41e4');
|
||||
assert.strictEqual(uint8ArrayToHex(out.subarray(32, 48)), '43269499cb6d59f4e3b9dda68098b673');
|
||||
});
|
||||
|
||||
it('matches a Node-crypto reference vector for a multi-word password', () => {
|
||||
const out = evpBytesToKeyMd5(stringToUint8Array('correct horse'), hexToUint8Array('0102030405060708'), 48);
|
||||
assert.strictEqual(uint8ArrayToHex(out.subarray(0, 32)), 'bcf8d941d9291141709c9d56360eb7148e3960ab3dc44d832c4028568545c91d');
|
||||
assert.strictEqual(uint8ArrayToHex(out.subarray(32, 48)), '5a7a1d12207f801d2f6f4cf578e8708c');
|
||||
});
|
||||
|
||||
it('returns exactly the requested number of bytes', () => {
|
||||
const pwd = stringToUint8Array('pw');
|
||||
const salt = hexToUint8Array('00000000000000ff');
|
||||
assert.strictEqual(evpBytesToKeyMd5(pwd, salt, 1).length, 1);
|
||||
assert.strictEqual(evpBytesToKeyMd5(pwd, salt, 15).length, 15);
|
||||
assert.strictEqual(evpBytesToKeyMd5(pwd, salt, 16).length, 16); // one MD5 block exactly
|
||||
assert.strictEqual(evpBytesToKeyMd5(pwd, salt, 17).length, 17); // one block + 1
|
||||
assert.strictEqual(evpBytesToKeyMd5(pwd, salt, 48).length, 48); // key + iv default
|
||||
assert.strictEqual(evpBytesToKeyMd5(pwd, salt, 65).length, 65); // multi-block + spillover
|
||||
});
|
||||
|
||||
it('is a prefix-stable stream (same first N bytes regardless of total length)', () => {
|
||||
const pwd = stringToUint8Array('xyz');
|
||||
const salt = hexToUint8Array('cafebabedeadbeef');
|
||||
const long = evpBytesToKeyMd5(pwd, salt, 64);
|
||||
for (const n of [1, 16, 17, 32, 48]) {
|
||||
assert.strictEqual(uint8ArrayToHex(evpBytesToKeyMd5(pwd, salt, n)), uint8ArrayToHex(long.subarray(0, n)));
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects non-integer or negative byteLength', () => {
|
||||
const pwd = stringToUint8Array('pw');
|
||||
const salt = hexToUint8Array('0102030405060708');
|
||||
assert.throws(() => evpBytesToKeyMd5(pwd, salt, -1));
|
||||
assert.throws(() => evpBytesToKeyMd5(pwd, salt, 1.5));
|
||||
assert.throws(() => evpBytesToKeyMd5(pwd, salt, NaN));
|
||||
assert.strictEqual(evpBytesToKeyMd5(pwd, salt, 0).length, 0);
|
||||
});
|
||||
});
|
||||
@ -208,17 +208,6 @@ describe('LNURL', function () {
|
||||
|
||||
assert.strictEqual(Lnurl.decipherAES(ciphertext, preimage, iv), '1234');
|
||||
});
|
||||
|
||||
it('decipherAES returns empty string on malformed input (preserves crypto-js contract)', () => {
|
||||
const preimage = 'bf62911aa53c017c27ba34391f694bc8bf8aaf59b4ebfd9020e66ac0412e189b';
|
||||
const validIv = 'eTGduB45hWTOxHj1dR+LJw==';
|
||||
// Non-block-aligned ciphertext — would throw under raw @noble/ciphers
|
||||
assert.strictEqual(Lnurl.decipherAES('not-base64-aligned', preimage, validIv), '');
|
||||
// Bad PKCS7 padding (random 16-byte block won't unpad cleanly)
|
||||
assert.strictEqual(Lnurl.decipherAES('AAAAAAAAAAAAAAAAAAAAAA==', preimage, validIv), '');
|
||||
// Empty ciphertext
|
||||
assert.strictEqual(Lnurl.decipherAES('', preimage, validIv), '');
|
||||
});
|
||||
});
|
||||
|
||||
describe('lightning address', function () {
|
||||
|
||||
@ -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