Compare commits

..

2 Commits

Author SHA1 Message Date
Overtorment
a50b50496f
Merge branch 'master' into renovate/react-native-permissions-5.x 2026-06-15 16:57:12 +01:00
renovate[bot]
6ac5710c56
fix(deps): update dependency react-native-permissions to v5.5.3 2026-06-13 18:12:05 +00:00
37 changed files with 617 additions and 1797 deletions

View File

@ -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'

View File

@ -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

View File

@ -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;
}

View File

@ -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 {

View File

@ -106,31 +106,23 @@ export class MultisigCosigner {
this._valid = false;
}
// is it coldcard / unchained json?
// is it coldcard json?
try {
const json = JSON.parse(data);
// p2wsh_p2sh (Coldcard), p2sh_p2wsh (Unchained)
// same script type with reversed naming
const xpub = json.p2wsh_p2sh || json.p2sh_p2wsh;
const path = (json.p2wsh_p2sh_deriv || json.p2sh_p2wsh_deriv)?.replace(/h/g, "'");
const p2sh_deriv = json.p2sh_deriv?.replace(/h/g, "'");
const p2wsh_deriv = json.p2wsh_deriv?.replace(/h/g, "'");
if (json.p2sh && p2sh_deriv && json.xfp) {
const cc = new MultisigCosigner(MultisigCosigner.exportToJson(json.xfp, json.p2sh, p2sh_deriv));
if (json.p2sh && json.p2sh_deriv && json.xfp) {
const cc = new MultisigCosigner(MultisigCosigner.exportToJson(json.xfp, json.p2sh, json.p2sh_deriv));
this._valid = true;
this._cosigners.push(cc);
}
if (xpub && path && json.xfp) {
const cc = new MultisigCosigner(MultisigCosigner.exportToJson(json.xfp, xpub, path));
if (json.p2wsh_p2sh && json.p2wsh_p2sh_deriv && json.xfp) {
const cc = new MultisigCosigner(MultisigCosigner.exportToJson(json.xfp, json.p2wsh_p2sh, json.p2wsh_p2sh_deriv));
this._valid = true;
this._cosigners.push(cc);
}
if (json.p2wsh && p2wsh_deriv && json.xfp) {
const cc = new MultisigCosigner(MultisigCosigner.exportToJson(json.xfp, json.p2wsh, p2wsh_deriv));
if (json.p2wsh && json.p2wsh_deriv && json.xfp) {
const cc = new MultisigCosigner(MultisigCosigner.exportToJson(json.xfp, json.p2wsh, json.p2wsh_deriv));
this._valid = true;
this._cosigners.push(cc);
}

View File

@ -658,12 +658,6 @@ export class LightningArkWallet extends LightningCustodianWallet {
const paymentResult = await this._arkadeSwaps.sendLightningPayment({ invoice });
this.last_paid_invoice_result = {
payment_preimage: paymentResult.preimage,
payment_hash: invoiceDetails.paymentHash,
payment_request: invoice,
};
console.log('Payment successful!');
console.log('Amount:', paymentResult.amount);
console.log('Preimage:', paymentResult.preimage);

View File

@ -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 (

View File

@ -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,

View File

@ -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}

View File

@ -7,18 +7,10 @@ import { useTheme } from './themes';
interface SafeAreaScrollViewProps extends ScrollViewProps {
floatingButtonHeight?: number;
headerHeight?: number; // Additional header height to account for (e.g., when headerTransparent is true)
disableDefaultTopPadding?: boolean;
}
const SafeAreaScrollView = forwardRef<ScrollView, SafeAreaScrollViewProps>((props, ref) => {
const {
style,
contentContainerStyle,
floatingButtonHeight = 0,
headerHeight = 0,
disableDefaultTopPadding = false,
...otherProps
} = props;
const { style, contentContainerStyle, floatingButtonHeight = 0, headerHeight = 0, ...otherProps } = props;
const { colors } = useTheme();
const insets = useSafeAreaInsets();
@ -40,10 +32,7 @@ const SafeAreaScrollView = forwardRef<ScrollView, SafeAreaScrollViewProps>((prop
if (headerHeight > 0) {
return headerHeight;
}
if (disableDefaultTopPadding) {
return 0;
}
// Preserve legacy behavior for existing screens
// iOS safe area or no status bar
return insets.top > 0 ? 5 : 0;
})(),
};
@ -59,7 +48,7 @@ const SafeAreaScrollView = forwardRef<ScrollView, SafeAreaScrollViewProps>((prop
// Now compose with contentContainerStyle to ensure passed styles override defaults
return StyleSheet.compose(basePadding, contentContainerStyle);
}, [insets, contentContainerStyle, floatingButtonHeight, headerHeight, disableDefaultTopPadding]);
}, [insets, contentContainerStyle, floatingButtonHeight, headerHeight]);
return (
<ScrollView

View File

@ -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,

View File

@ -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}

View File

@ -1,8 +1,8 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import Clipboard from '@react-native-clipboard/clipboard';
import { Platform, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import Animated, { useAnimatedStyle, useSharedValue, withSpring, withTiming } from 'react-native-reanimated';
import LinearGradient from 'react-native-linear-gradient';
import { useTheme } from './themes';
import { LightningArkWallet } from '../class/wallets/lightning-ark-wallet';
import { LightningCustodianWallet } from '../class/wallets/lightning-custodian-wallet';
import { MultisigHDWallet } from '../class/wallets/multisig-hd-wallet';
@ -14,39 +14,35 @@ import { FiatUnit } from '../models/fiatUnit';
import { BlurredBalanceView } from './BlurredBalanceView';
import { useSettings } from '../hooks/context/useSettings';
import ToolTipMenu from './TooltipMenu';
import useAnimateOnChange from '../hooks/useAnimateOnChange';
import { useLocale } from '@react-navigation/native';
import ActionSheet from '../screen/ActionSheet';
const HERO_BASE_BODY_MIN_HEIGHT = 120;
const HERO_MIN_BODY_HEIGHT = Math.round(HERO_BASE_BODY_MIN_HEIGHT * 1.2);
const HERO_BOTTOM_PADDING = 32;
const WALLET_LABEL_TOP_GAP = 32;
interface TransactionsNavigationHeaderProps {
wallet: TWallet;
unit: BitcoinUnit;
headerOverlayHeight: number;
onWalletUnitChange: (unit: BitcoinUnit) => void;
onManageFundsPressed?: (id?: string) => void;
onWalletBalanceVisibilityChange?: (shouldHideBalance: boolean) => void;
onWalletBalanceVisibilityChange?: (isShouldBeVisible: boolean) => void;
unitSwitching?: boolean;
}
const TransactionsNavigationHeader: React.FC<TransactionsNavigationHeaderProps> = ({
wallet,
headerOverlayHeight,
onWalletUnitChange,
onManageFundsPressed,
onWalletBalanceVisibilityChange,
unit = BitcoinUnit.BTC,
unitSwitching = false,
}) => {
const { colors } = useTheme();
const { hideBalance } = wallet;
const isLightningWallet = wallet.type === LightningCustodianWallet.type || wallet.type === LightningArkWallet.type;
const [allowOnchainAddress, setAllowOnchainAddress] = useState(isLightningWallet);
const { preferredFiatCurrency } = useSettings();
const { direction } = useLocale();
const balanceOpacity = useSharedValue(1);
const balanceTranslateY = useSharedValue(0);
const previousBalance = useRef<string | undefined>(undefined);
const verifyIfWalletAllowsOnchainAddress = useCallback(() => {
if (isLightningWallet) {
@ -77,14 +73,13 @@ const TransactionsNavigationHeader: React.FC<TransactionsNavigationHeaderProps>
const handleBalanceVisibility = useCallback(() => {
onWalletBalanceVisibilityChange?.(!hideBalance);
}, [hideBalance, onWalletBalanceVisibilityChange]);
}, [onWalletBalanceVisibilityChange, hideBalance]);
const changeWalletBalanceUnit = () => {
if (hideBalance) {
return;
}
let newWalletPreferredUnit = wallet.getPreferredBalanceUnit();
console.debug('[UnitSwitch/UI] tap unit change', { walletID: wallet.getID?.(), current: newWalletPreferredUnit });
if (newWalletPreferredUnit === BitcoinUnit.BTC) {
newWalletPreferredUnit = BitcoinUnit.SATS;
} else if (newWalletPreferredUnit === BitcoinUnit.SATS) {
@ -93,6 +88,7 @@ const TransactionsNavigationHeader: React.FC<TransactionsNavigationHeaderProps>
newWalletPreferredUnit = BitcoinUnit.BTC;
}
console.debug('[UnitSwitch/UI] next unit resolved', { walletID: wallet.getID?.(), next: newWalletPreferredUnit });
onWalletUnitChange(newWalletPreferredUnit);
};
@ -107,9 +103,9 @@ const TransactionsNavigationHeader: React.FC<TransactionsNavigationHeaderProps>
const onPressMenuItem = useCallback(
(id: string) => {
if (id === actionKeys.WalletBalanceVisibility) {
if (id === 'walletBalanceVisibility') {
handleBalanceVisibility();
} else if (id === actionKeys.CopyToClipboard) {
} else if (id === 'copyToClipboard') {
handleCopyPress();
}
},
@ -144,160 +140,148 @@ const TransactionsNavigationHeader: React.FC<TransactionsNavigationHeaderProps>
}, [unit, currentBalance]);
const balance = !wallet.hideBalance && formattedBalance;
const safeBalance = balance ? String(balance) : undefined;
useEffect(() => {
if (hideBalance) {
previousBalance.current = undefined;
balanceOpacity.value = 1;
balanceTranslateY.value = 0;
return;
}
if (previousBalance.current !== undefined && previousBalance.current !== safeBalance) {
balanceOpacity.value = 0;
balanceTranslateY.value = 6;
balanceOpacity.value = withTiming(1, { duration: 180 });
balanceTranslateY.value = withSpring(0, { damping: 16, stiffness: 220 });
}
previousBalance.current = safeBalance;
}, [safeBalance, hideBalance, balanceOpacity, balanceTranslateY]);
const balanceAnimationKey = useMemo(
() => `${wallet.getID?.() ?? ''}-${unit}-${hideBalance}-${safeBalance ?? ''}`,
[safeBalance, hideBalance, unit, wallet],
);
const balanceAnimatedStyle = useAnimateOnChange(balanceAnimationKey);
const animatedBalanceTextStyle = useAnimatedStyle(() => ({
opacity: balanceOpacity.value,
transform: [{ translateY: balanceTranslateY.value }],
}));
const toolTipWalletBalanceActions = useMemo(() => {
return hideBalance
? [
{
id: actionKeys.WalletBalanceVisibility,
id: 'walletBalanceVisibility',
text: loc.transactions.details_balance_show,
icon: actionIcons.Eye,
icon: {
iconValue: 'eye',
},
},
]
: [
{
id: actionKeys.WalletBalanceVisibility,
id: 'walletBalanceVisibility',
text: loc.transactions.details_balance_hide,
icon: actionIcons.EyeSlash,
icon: {
iconValue: 'eye.slash',
},
},
{
id: actionKeys.CopyToClipboard,
id: 'copyToClipboard',
text: loc.transactions.details_copy,
icon: actionIcons.Clipboard,
icon: {
iconValue: 'doc.on.doc',
},
},
];
}, [hideBalance]);
useEffect(() => {
console.debug('[UnitSwitch/UI] render state', {
walletID: wallet.getID?.(),
unit,
hideBalance,
preferredFiat: preferredFiatCurrency?.endPointKey,
switching: unitSwitching,
});
}, [wallet, unit, hideBalance, preferredFiatCurrency, unitSwitching]);
return (
<View
style={[
styles.lineaderGradient,
{
paddingTop: headerOverlayHeight,
minHeight: headerOverlayHeight + HERO_MIN_BODY_HEIGHT,
backgroundColor: WalletGradient.headerColorFor(wallet.type),
},
]}
>
<LinearGradient colors={WalletGradient.gradientsFor(wallet.type)} style={StyleSheet.absoluteFill} />
<LinearGradient colors={WalletGradient.gradientsFor(wallet.type)} style={styles.lineaderGradient}>
<View style={styles.contentContainer}>
<Text testID="WalletLabel" numberOfLines={1} style={[styles.walletLabel, { writingDirection: direction }]}>
{wallet.getLabel()}
</Text>
<View style={styles.balanceSection}>
<View style={styles.walletBalanceAndUnitContainer}>
<ToolTipMenu
shouldOpenOnLongPress
isButton
enableAndroidRipple={false}
buttonStyle={styles.walletBalance}
onPressMenuItem={onPressMenuItem}
actions={toolTipWalletBalanceActions}
>
<View style={styles.walletBalance}>
{hideBalance ? (
<BlurredBalanceView />
) : (
<Text
<Animated.View style={[styles.walletBalanceAndUnitContainer, balanceAnimatedStyle]}>
<ToolTipMenu
shouldOpenOnLongPress
isButton
enableAndroidRipple={false}
buttonStyle={styles.walletBalance}
onPressMenuItem={onPressMenuItem}
actions={toolTipWalletBalanceActions}
>
<View style={styles.walletBalance}>
{hideBalance ? (
<BlurredBalanceView />
) : (
<View key={`wallet-balance-textwrap-${wallet.getID?.() ?? ''}-${String(balance)}`}>
<Animated.Text
key={`wallet-balance-text-${wallet.getID?.() ?? ''}-${String(balance)}`} // force recreation on balance change for RTL correctness
testID="WalletBalance"
numberOfLines={1}
minimumFontScale={0.5}
adjustsFontSizeToFit
style={styles.walletBalanceText}
style={[styles.walletBalanceText, animatedBalanceTextStyle]}
>
{balance}
</Text>
)}
</View>
</ToolTipMenu>
{!hideBalance && (
<TouchableOpacity style={styles.walletPreferredUnitView} onPress={changeWalletBalanceUnit} disabled={unitSwitching}>
<Text style={styles.walletPreferredUnitText}>
{unit === BitcoinUnit.LOCAL_CURRENCY ? (preferredFiatCurrency?.endPointKey ?? FiatUnit.USD) : unit}
</Text>
</TouchableOpacity>
)}
</View>
{(wallet.type === LightningCustodianWallet.type || wallet.type === LightningArkWallet.type) && allowOnchainAddress && (
<TouchableOpacity style={styles.manageFundsButton} accessibilityRole="button" onPress={showManageFundsActionSheet}>
<Text style={styles.manageFundsButtonText}>{loc.lnd.title}</Text>
</TouchableOpacity>
)}
</View>
</Animated.Text>
</View>
)}
</View>
</ToolTipMenu>
<TouchableOpacity style={styles.walletPreferredUnitView} onPress={changeWalletBalanceUnit} disabled={unitSwitching}>
<Text style={styles.walletPreferredUnitText}>
{unit === BitcoinUnit.LOCAL_CURRENCY ? (preferredFiatCurrency?.endPointKey ?? FiatUnit.USD) : unit}
</Text>
</TouchableOpacity>
</Animated.View>
{(wallet.type === LightningCustodianWallet.type || wallet.type === LightningArkWallet.type) && allowOnchainAddress && (
<TouchableOpacity style={styles.manageFundsButton} accessibilityRole="button" onPress={showManageFundsActionSheet}>
<Text style={styles.manageFundsButtonText}>{loc.lnd.title}</Text>
</TouchableOpacity>
)}
{wallet.type === MultisigHDWallet.type && (
<TouchableOpacity style={styles.manageFundsButton} accessibilityRole="button" onPress={() => handleManageFundsPressed()}>
<Text style={styles.manageFundsButtonText}>{loc.multisig.manage_keys}</Text>
</TouchableOpacity>
)}
</View>
<View style={styles.bottomBarSpacer}>
<View
style={[
styles.bottomBar,
{
backgroundColor: colors.background,
...Platform.select({
ios: { shadowColor: colors.shadowColor },
android: {},
}),
},
]}
/>
</View>
</View>
</LinearGradient>
);
};
const styles = StyleSheet.create({
lineaderGradient: {
minHeight: 140,
justifyContent: 'flex-start',
position: 'relative',
},
contentContainer: {
flex: 1,
paddingTop: WALLET_LABEL_TOP_GAP,
paddingHorizontal: 16,
paddingBottom: HERO_BOTTOM_PADDING,
},
bottomBarSpacer: {
position: 'relative',
height: 12,
marginBottom: 0,
},
bottomBar: {
position: 'absolute',
left: 0,
right: 0,
bottom: -1,
height: 13,
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
...Platform.select({
ios: {
shadowOffset: { width: 0, height: -8 },
shadowOpacity: 0.1,
shadowRadius: 6,
},
android: {
elevation: 0.5,
},
}),
padding: 15,
},
walletLabel: {
backgroundColor: 'transparent',
fontSize: 19,
color: 'rgba(255, 255, 255, 0.7)',
marginBottom: 4,
color: '#fff',
marginBottom: 10,
},
walletBalance: {
flexShrink: 1,
marginRight: 6,
minHeight: 39,
justifyContent: 'center',
},
balanceSection: {
flexDirection: 'column',
alignItems: 'flex-start',
},
manageFundsButton: {
marginTop: 14,
@ -318,13 +302,13 @@ const styles = StyleSheet.create({
walletBalanceAndUnitContainer: {
flexDirection: 'row',
alignItems: 'center',
paddingRight: 10,
paddingRight: 10, // Ensure there's some padding to the right
},
walletBalanceText: {
color: '#fff',
fontWeight: 'bold',
fontSize: 36,
flexShrink: 1,
flexShrink: 1, // Allow the text to shrink if there's not enough space
},
walletPreferredUnitView: {
justifyContent: 'center',

View File

@ -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}

View File

@ -31,8 +31,6 @@ export { platformColors } from '../themes';
export const isAndroid = Platform.OS === 'android';
const isIOS = Platform.OS === 'ios';
const iosMajorVersion = isIOS ? Number(String(Platform.Version).split('.')[0]) : 0;
export const isIOS26OrHigher = isIOS && Number.isFinite(iosMajorVersion) && iosMajorVersion >= 26;
export const platformSizing = {
horizontalPadding: isIOS ? 16 : 20,
@ -109,15 +107,6 @@ export const getSettingsHeaderOptions = (
const cardColor = colors.lightButton ?? colors.modal ?? colors.elevated ?? defaultBackgroundColor;
const headerBackgroundColor = isIOS ? (dark ? defaultBackgroundColor : cardColor) : defaultBackgroundColor;
if (isIOS26OrHigher) {
return {
title,
headerLargeTitle: true,
headerLargeTitleShadowVisible: true,
headerBackButtonDisplayMode: 'minimal' as const,
};
}
return {
title,
headerLargeTitle: isIOS,
@ -203,7 +192,6 @@ export const SettingsScrollView = forwardRef<ScrollView, SettingsScrollViewProps
ref={ref}
style={[style, { backgroundColor: screenBackgroundColor }]}
headerHeight={resolvedHeaderHeight}
disableDefaultTopPadding={isIOS26OrHigher}
floatingButtonHeight={floatingButtonHeight}
contentContainerStyle={[staticStyles.contentContainer, contentContainerStyle]}
{...rest}

View File

@ -14,8 +14,6 @@ export const BlueDefaultTheme = {
foregroundColor: '#0c2550',
borderTopColor: 'rgba(0, 0, 0, 0.1)',
buttonBackgroundColor: '#ccddf9',
/** Softer fill for native iOS 26+ prominent header bar buttons (derived from `buttonBackgroundColor`). */
headerProminentButtonBackgroundColor: 'rgba(204, 221, 249, 0.9)',
buttonTextColor: '#0c2550',
secondButtonTextColor: '#50555C',
buttonAlternativeTextColor: '#2f5fb3',
@ -103,7 +101,6 @@ export const BlueDarkTheme: Theme = {
foregroundColor: '#ffffff',
buttonDisabledBackgroundColor: '#3A3A3C',
buttonBackgroundColor: '#3A3A3C',
headerProminentButtonBackgroundColor: 'rgba(58, 58, 60, 0.6)',
buttonTextColor: '#ffffff',
lightButton: 'rgba(255,255,255,.1)',
buttonAlternativeTextColor: '#ffffff',

View File

@ -245,6 +245,8 @@
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>UIDesignRequiresCompatibility</key>
<true/>
<key>UTExportedTypeDeclarations</key>
<array>
<dict>

View File

@ -1,6 +1,6 @@
import React, { lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Animated, AppState, View, Platform, PlatformColor, Text, StyleSheet, Pressable } from 'react-native';
import type { NativeStackHeaderItem, NativeStackNavigationOptions } from '@react-navigation/native-stack';
import { NativeStackNavigationOptions } from '@react-navigation/native-stack';
import navigationStyle, { CloseButtonPosition } from '../components/navigationStyle';
import { useTheme } from '../components/themes';
import { useExtendedNavigation } from '../hooks/useExtendedNavigation';
@ -57,9 +57,6 @@ import { ConnectionPollContext } from './ConnectionPollContext';
import ManageWallets from '../screen/wallets/ManageWallets';
import ReceiveDetails from '../screen/receive/ReceiveDetails';
import ReceiveCustomAmountSheet from '../screen/receive/ReceiveCustomAmountSheet';
import { isIOS26OrHigher } from '../components/platform';
type HeaderRightItem = ReturnType<NonNullable<NativeStackNavigationOptions['unstable_headerRightItems']>>[number];
const PaymentCodesList = lazy(() => import('../screen/wallets/PaymentCodesList'));
const PaymentCodesListComponent = withLazySuspense(PaymentCodesList);
@ -153,15 +150,6 @@ const DetailViewStackScreensStack = () => {
navigation.navigate('AddWalletRoot');
}, [navigation]);
const navigateToSettings = useCallback(() => {
navigation.navigate('DrawerRoot', {
screen: 'DetailViewStackScreensStack',
params: {
screen: 'Settings',
},
});
}, [navigation]);
const RightBarButtons = useMemo(
() =>
sizeClass === SizeClass.Large ? (
@ -231,53 +219,6 @@ const DetailViewStackScreensStack = () => {
return null;
};
if (isIOS26OrHigher) {
// Status pills: `unstable_headerLeftItems` + `hidesSharedBackground` avoids the
// navigation bar's shared liquid-glass chrome on the pill (solid colors only).
return {
title: sizeClass === SizeClass.Large ? loc.wallets.list_title : '',
headerLargeTitle: false,
headerTransparent: true,
unstable_headerLeftItems: (): NativeStackHeaderItem[] => {
const element = renderHeaderLeft();
if (element == null) {
return [];
}
return [{ type: 'custom', element, hidesSharedBackground: true }];
},
unstable_headerRightItems: () => {
if (isDesktop) {
return [];
}
const items: HeaderRightItem[] = [
{
type: 'button',
label: loc.wallets.add_title,
icon: { type: 'sfSymbol', name: 'plus' },
variant: 'prominent',
tintColor: theme.colors.headerProminentButtonBackgroundColor,
identifier: 'AddWalletButton',
accessibilityLabel: 'AddWalletButton',
sharesBackground: false,
onPress: navigateToAddWallet,
},
];
if (sizeClass !== SizeClass.Large) {
items.push({
type: 'button',
label: loc.settings.default_title,
icon: { type: 'sfSymbol', name: 'ellipsis' },
identifier: 'SettingsButton',
accessibilityLabel: 'SettingsButton',
sharesBackground: false,
onPress: navigateToSettings,
});
}
return items;
},
};
}
return {
title: sizeClass === SizeClass.Large ? loc.wallets.list_title : '',
headerLargeTitle: false,
@ -292,7 +233,6 @@ const DetailViewStackScreensStack = () => {
RightBarButtons,
sizeClass,
theme.colors.customHeader,
theme.colors.headerProminentButtonBackgroundColor,
theme.colors.foregroundColor,
theme.colors.lightButton,
theme.colors.redBG,
@ -302,8 +242,6 @@ const DetailViewStackScreensStack = () => {
electrumConnected,
isElectrumDisabled,
navigateToElectrumSettings,
navigateToAddWallet,
navigateToSettings,
walletTransactionUpdateStatus,
]);
@ -313,14 +251,6 @@ const DetailViewStackScreensStack = () => {
// Consistent header configuration for all settings screens
const getSettingsHeaderOptions = (title: string) => {
if (isIOS26OrHigher) {
return {
title,
headerLargeTitle: true,
headerLargeTitleShadowVisible: true,
headerBackButtonDisplayMode: 'minimal' as const,
};
}
// Use PlatformColor for iOS to match the Settings component, fallback to theme color
const titleColor = Platform.OS === 'ios' ? PlatformColor('label') : theme.colors.foregroundColor;
// Convert PlatformColor to string for TypeScript compatibility
@ -343,9 +273,6 @@ const DetailViewStackScreensStack = () => {
};
};
const settingsScreenOptions = (title: string) =>
isIOS26OrHigher ? getSettingsHeaderOptions(title) : navigationStyle(getSettingsHeaderOptions(title))(theme);
return (
<ConnectionPollContext.Provider value={connectionPollContextValue}>
<DetailViewStack.Navigator
@ -412,14 +339,22 @@ const DetailViewStackScreensStack = () => {
options={navigationStyle({ title: loc.lndViewInvoice.additional_info })(theme)}
/>
<DetailViewStack.Screen name="Broadcast" component={Broadcast} options={settingsScreenOptions(loc.send.create_broadcast)} />
<DetailViewStack.Screen
name="Broadcast"
component={Broadcast}
options={navigationStyle(getSettingsHeaderOptions(loc.send.create_broadcast))(theme)}
/>
<DetailViewStack.Screen
name="IsItMyAddress"
component={IsItMyAddress}
initialParams={{ address: undefined }}
options={settingsScreenOptions(loc.is_it_my_address.title)}
options={navigationStyle(getSettingsHeaderOptions(loc.is_it_my_address.title))(theme)}
/>
<DetailViewStack.Screen
name="GenerateWord"
component={GenerateWord}
options={navigationStyle(getSettingsHeaderOptions(loc.autofill_word.title))(theme)}
/>
<DetailViewStack.Screen name="GenerateWord" component={GenerateWord} options={settingsScreenOptions(loc.autofill_word.title)} />
<DetailViewStack.Screen
name="LnurlPay"
component={LnurlPay}
@ -462,90 +397,115 @@ const DetailViewStackScreensStack = () => {
<DetailViewStack.Screen
name="Settings"
component={Settings}
options={
isIOS26OrHigher
? getSettingsHeaderOptions(loc.settings.header)
: navigationStyle({
title: loc.settings.header,
headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '',
headerShadowVisible: false,
// headerLargeTitle is iOS-only, disable on Android for better compatibility with older versions
headerLargeTitle: Platform.OS === 'ios',
headerLargeTitleStyle:
Platform.OS === 'ios'
? {
color:
typeof theme.colors.foregroundColor === 'string'
? theme.colors.foregroundColor
: String(theme.colors.foregroundColor),
}
: undefined,
headerTitleStyle: {
options={navigationStyle({
title: loc.settings.header,
headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '',
headerShadowVisible: false,
// headerLargeTitle is iOS-only, disable on Android for better compatibility with older versions
headerLargeTitle: Platform.OS === 'ios',
headerLargeTitleStyle:
Platform.OS === 'ios'
? {
color:
typeof theme.colors.foregroundColor === 'string'
? theme.colors.foregroundColor
: String(theme.colors.foregroundColor),
},
headerTransparent: false,
headerBlurEffect: undefined,
headerStyle: {
backgroundColor: settingsHeaderBackgroundColor,
},
animationTypeForReplace: 'push',
})(theme)
}
}
: undefined,
headerTitleStyle: {
color: typeof theme.colors.foregroundColor === 'string' ? theme.colors.foregroundColor : String(theme.colors.foregroundColor),
},
headerTransparent: false,
headerBlurEffect: undefined,
headerStyle: {
backgroundColor: settingsHeaderBackgroundColor,
},
animationTypeForReplace: 'push',
})(theme)}
/>
<DetailViewStack.Screen
name="Currency"
component={Currency}
options={navigationStyle(getSettingsHeaderOptions(loc.settings.currency))(theme)}
/>
<DetailViewStack.Screen
name="GeneralSettings"
component={GeneralSettings}
options={navigationStyle(getSettingsHeaderOptions(loc.settings.general))(theme)}
/>
<DetailViewStack.Screen name="Currency" component={Currency} options={settingsScreenOptions(loc.settings.currency)} />
<DetailViewStack.Screen name="GeneralSettings" component={GeneralSettings} options={settingsScreenOptions(loc.settings.general)} />
<DetailViewStack.Screen
name="PlausibleDeniability"
component={PlausibleDeniability}
options={settingsScreenOptions(loc.plausibledeniability.title)}
options={navigationStyle(getSettingsHeaderOptions(loc.plausibledeniability.title))(theme)}
/>
<DetailViewStack.Screen
name="Licensing"
component={Licensing}
options={navigationStyle(getSettingsHeaderOptions(loc.settings.license))(theme)}
/>
<DetailViewStack.Screen
name="NetworkSettings"
component={NetworkSettings}
options={navigationStyle(getSettingsHeaderOptions(loc.settings.network))(theme)}
/>
<DetailViewStack.Screen name="Licensing" component={Licensing} options={settingsScreenOptions(loc.settings.license)} />
<DetailViewStack.Screen name="NetworkSettings" component={NetworkSettings} options={settingsScreenOptions(loc.settings.network)} />
<DetailViewStack.Screen
name="SettingsBlockExplorer"
component={SettingsBlockExplorer}
options={settingsScreenOptions(loc.settings.block_explorer)}
options={navigationStyle(getSettingsHeaderOptions(loc.settings.block_explorer))(theme)}
/>
<DetailViewStack.Screen name="About" component={About} options={settingsScreenOptions(loc.settings.about)} />
<DetailViewStack.Screen
name="About"
component={About}
options={navigationStyle(getSettingsHeaderOptions(loc.settings.about))(theme)}
/>
{/* <DetailViewStack.Screen
name="DefaultView"
component={DefaultView}
options={settingsScreenOptions(loc.settings.default_title)}
options={navigationStyle(getSettingsHeaderOptions(loc.settings.default_title))(theme)}
/> */}
<DetailViewStack.Screen
name="ElectrumSettings"
component={ElectrumSettings}
options={settingsScreenOptions(loc.settings.electrum_settings_server)}
options={navigationStyle(getSettingsHeaderOptions(loc.settings.electrum_settings_server))(theme)}
initialParams={{ server: undefined }}
/>
<DetailViewStack.Screen
name="EncryptStorage"
component={EncryptStorage}
options={settingsScreenOptions(loc.settings.encrypt_title)}
options={navigationStyle(getSettingsHeaderOptions(loc.settings.encrypt_title))(theme)}
/>
<DetailViewStack.Screen
name="Language"
component={Language}
options={navigationStyle(getSettingsHeaderOptions(loc.settings.language))(theme)}
/>
<DetailViewStack.Screen name="Language" component={Language} options={settingsScreenOptions(loc.settings.language)} />
<DetailViewStack.Screen
name="LightningSettings"
component={LightningSettings}
options={settingsScreenOptions(loc.settings.lightning_settings)}
options={navigationStyle(getSettingsHeaderOptions(loc.settings.lightning_settings))(theme)}
/>
<DetailViewStack.Screen
name="NotificationSettings"
component={NotificationSettings}
options={settingsScreenOptions(loc.settings.notifications)}
options={navigationStyle(getSettingsHeaderOptions(loc.settings.notifications))(theme)}
/>
<DetailViewStack.Screen
name="SelfTest"
component={SelfTest}
options={navigationStyle(getSettingsHeaderOptions(loc.settings.selfTest))(theme)}
/>
<DetailViewStack.Screen name="SelfTest" component={SelfTest} options={settingsScreenOptions(loc.settings.selfTest)} />
<DetailViewStack.Screen
name="ReleaseNotes"
component={ReleaseNotes}
options={settingsScreenOptions(loc.settings.about_release_notes)}
options={navigationStyle(getSettingsHeaderOptions(loc.settings.about_release_notes))(theme)}
/>
<DetailViewStack.Screen
name="SettingsTools"
component={SettingsTools}
options={navigationStyle(getSettingsHeaderOptions(loc.settings.tools))(theme)}
/>
<DetailViewStack.Screen name="SettingsTools" component={SettingsTools} options={settingsScreenOptions(loc.settings.tools)} />
<DetailViewStack.Screen
name="PromptPasswordConfirmationSheet"
component={PromptPasswordConfirmationSheet}

View File

@ -1,98 +1,44 @@
import React from 'react';
import { Platform, TouchableOpacity, StyleSheet } from 'react-native';
import type { NativeStackHeaderItem, NativeStackNavigationOptions } from '@react-navigation/native-stack';
import { TouchableOpacity, StyleSheet } from 'react-native';
import Icon from '../../components/Icon';
import WalletGradient from '../../class/wallet-gradient';
import { NativeStackNavigationOptions } from '@react-navigation/native-stack';
import { DetailViewStackParamList } from '../DetailViewStackParamList';
import { navigationRef } from '../../NavigationService';
import { RouteProp } from '@react-navigation/native';
import { isDesktop } from '../../blue_modules/environment';
import { isIOS26OrHigher } from '../../components/platform';
import loc from '../../loc';
export type WalletTransactionsRouteProps = RouteProp<DetailViewStackParamList, 'WalletTransactions'>;
const HERO_HEADER_ICON_COLOR = '#FFFFFF';
const getWalletTransactionsOptions = ({ route }: { route: WalletTransactionsRouteProps }): NativeStackNavigationOptions => {
const { isLoading = false, walletID, walletType } = route.params;
const navigateToWalletDetails = (walletID: string) => {
navigationRef.navigate('WalletDetails', {
walletID,
});
};
const onPress = () => {
navigationRef.navigate('WalletDetails', {
walletID,
});
};
/** Material "more" button for WalletTransactions header (preiOS 26 and Android). */
export const createWalletDetailsHeaderRight = ({
walletID,
isLoading = false,
iconColor = HERO_HEADER_ICON_COLOR,
}: {
walletID: string;
isLoading?: boolean;
iconColor?: string;
}): (() => React.ReactElement) => {
return () => (
<TouchableOpacity
accessibilityRole="button"
testID="WalletDetails"
disabled={isLoading}
style={styles.walletDetails}
onPress={() => navigateToWalletDetails(walletID)}
>
<Icon name="more-horiz" type="material" size={22} color={iconColor} />
const RightButton = (
<TouchableOpacity accessibilityRole="button" testID="WalletDetails" disabled={isLoading} style={styles.walletDetails} onPress={onPress}>
<Icon name="more-horiz" type="material" size={22} color="#FFFFFF" />
</TouchableOpacity>
);
};
/** Native toolbar ellipsis for WalletTransactions on iOS 26+. */
export const createWalletDetailsHeaderRightItems = ({
isLoading = false,
walletID,
}: {
isLoading?: boolean;
walletID: string;
}): (() => NativeStackHeaderItem[]) => {
return () => [
{
type: 'button',
label: loc.wallets.details_title,
icon: { type: 'sfSymbol', name: 'ellipsis' },
identifier: 'WalletDetails',
accessibilityLabel: 'WalletDetails',
sharesBackground: false,
onPress: () => navigateToWalletDetails(walletID),
disabled: isLoading,
},
];
};
const backgroundColor = WalletGradient.headerColorFor(walletType);
const getWalletTransactionsOptions = ({ route }: { route: WalletTransactionsRouteProps }): NativeStackNavigationOptions => {
const { isLoading = false, walletID } = route.params;
const base: NativeStackNavigationOptions = {
return {
title: '',
headerBackTitleStyle: { fontSize: 0 },
headerTransparent: true,
headerStyle: {
backgroundColor: 'transparent',
backgroundColor,
},
headerBackButtonDisplayMode: 'minimal',
headerShadowVisible: false,
headerTintColor: HERO_HEADER_ICON_COLOR,
headerBlurEffect: undefined,
headerTintColor: '#FFFFFF',
statusBarStyle: 'light',
headerBackTitle: undefined,
headerRight: createWalletDetailsHeaderRight({ walletID, isLoading, iconColor: HERO_HEADER_ICON_COLOR }),
headerRight: () => RightButton,
};
if (Platform.OS === 'ios' && isIOS26OrHigher && !isDesktop) {
return {
...base,
headerRight: undefined,
experimental_userInterfaceStyle: 'dark' as const,
unstable_headerRightItems: createWalletDetailsHeaderRightItems({ isLoading, walletID }),
};
}
return base;
};
const styles = StyleSheet.create({

148
package-lock.json generated
View File

@ -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",
@ -36,10 +35,10 @@
"@react-native/codegen": "0.85.3",
"@react-native/gradle-plugin": "0.85.3",
"@react-native/metro-config": "0.85.3",
"@react-navigation/devtools": "7.0.62",
"@react-navigation/drawer": "7.12.0",
"@react-navigation/native": "7.3.1",
"@react-navigation/native-stack": "7.17.3",
"@react-navigation/devtools": "7.0.58",
"@react-navigation/drawer": "7.10.2",
"@react-navigation/native": "7.2.4",
"@react-navigation/native-stack": "7.15.1",
"@scure/base": "2.0.0",
"@spsina/bip47": "github:BlueWallet/bip47#df82345",
"aezeed": "0.0.5",
@ -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",
@ -119,13 +119,14 @@
"@jest/reporters": "^27.5.1",
"@react-native/eslint-config": "^0.85.3",
"@react-native/jest-preset": "0.85.3",
"@react-native/js-polyfills": "^0.86.0",
"@react-native/js-polyfills": "^0.85.3",
"@react-native/metro-babel-transformer": "^0.85.3",
"@react-native/typescript-config": "^0.85.3",
"@testing-library/react-native": "^13.0.1",
"@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/"
@ -4714,16 +4701,6 @@
"react": "^19.2.3"
}
},
"node_modules/@react-native/jest-preset/node_modules/@react-native/js-polyfills": {
"version": "0.85.3",
"resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.85.3.tgz",
"integrity": "sha512-U2+aMshIXf1uFn77tpBb/xhHWB9vkVrMpt7kkucAugF8hJKYTDGB587X7WwelHduK2KBfhl4giSv0rzZGoef9A==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/jest-preset/node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
@ -4732,10 +4709,9 @@
"license": "MIT"
},
"node_modules/@react-native/js-polyfills": {
"version": "0.86.0",
"resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.86.0.tgz",
"integrity": "sha512-zYy/Cjd1VTnZ2iCNaG9bDF9C3l2ntESiPRscjIlI5FKugu6aeTwsDSv1aI8Bc4Kp3vEdoVg+UQhLAhE4svREaQ==",
"dev": true,
"version": "0.85.3",
"resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.85.3.tgz",
"integrity": "sha512-U2+aMshIXf1uFn77tpBb/xhHWB9vkVrMpt7kkucAugF8hJKYTDGB587X7WwelHduK2KBfhl4giSv0rzZGoef9A==",
"license": "MIT",
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
@ -4774,15 +4750,6 @@
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/metro-config/node_modules/@react-native/js-polyfills": {
"version": "0.85.3",
"resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.85.3.tgz",
"integrity": "sha512-U2+aMshIXf1uFn77tpBb/xhHWB9vkVrMpt7kkucAugF8hJKYTDGB587X7WwelHduK2KBfhl4giSv0rzZGoef9A==",
"license": "MIT",
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/normalize-colors": {
"version": "0.85.3",
"resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.85.3.tgz",
@ -4820,12 +4787,12 @@
}
},
"node_modules/@react-navigation/core": {
"version": "7.20.0",
"resolved": "https://registry.npmjs.org/@react-navigation/core/-/core-7.20.0.tgz",
"integrity": "sha512-Lqw5cDQWWxiQnaWv6RhQV95Wr4fh+38/IFVNn1grssyLWV+wXGJjlucXOoU7EVh9jdtcLT8pGyzvsyrvSDywWA==",
"version": "7.17.4",
"resolved": "https://registry.npmjs.org/@react-navigation/core/-/core-7.17.4.tgz",
"integrity": "sha512-Rv9E2oNNQEkPGpmu9q+vJwGJRSQR6LBg5L+Yo1QHjtwGbHUbjkIKOdYymDZoZYgNzX2OD4rAIlfuzbDKa3cCeA==",
"license": "MIT",
"dependencies": {
"@react-navigation/routers": "^7.6.0",
"@react-navigation/routers": "^7.5.5",
"escape-string-regexp": "^4.0.0",
"fast-deep-equal": "^3.1.3",
"nanoid": "^3.3.11",
@ -4845,9 +4812,9 @@
"license": "MIT"
},
"node_modules/@react-navigation/devtools": {
"version": "7.0.62",
"resolved": "https://registry.npmjs.org/@react-navigation/devtools/-/devtools-7.0.62.tgz",
"integrity": "sha512-Xl+HhZmz0tzJCH13KCs19xYQWPfkQFfYd7Mxv5MnpFdYuxkmvedPJilwAhcTtJc+4PMtQ7sR0Jqv7Ssg4CPblg==",
"version": "7.0.58",
"resolved": "https://registry.npmjs.org/@react-navigation/devtools/-/devtools-7.0.58.tgz",
"integrity": "sha512-WpADcM0n+QHP1RMMmKZPc4reuvwTyX41gnJCdipjNUG0+VBNOkDyJZpAkeJqOJg2BIjSwsKcTAph3xkmXBjXVA==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
@ -4859,18 +4826,18 @@
}
},
"node_modules/@react-navigation/drawer": {
"version": "7.12.0",
"resolved": "https://registry.npmjs.org/@react-navigation/drawer/-/drawer-7.12.0.tgz",
"integrity": "sha512-OP8ti/ESCPng79/UzafQxYYP/EVHmgSCnNL91RGnT3ghsIpjr8xut5Ax+5N5+vwfEWBbHaxPCeuVHwukcmdtQw==",
"version": "7.10.2",
"resolved": "https://registry.npmjs.org/@react-navigation/drawer/-/drawer-7.10.2.tgz",
"integrity": "sha512-/ccYFvBPJNzOYioiMQsqjAR4dcQ+7+yjzcuMDTKgsMahLD7Jn7FdOFNtGwMaIQWhfK8KFVMH2KOXAlH/uAGZXw==",
"license": "MIT",
"dependencies": {
"@react-navigation/elements": "^2.9.23",
"@react-navigation/elements": "^2.9.18",
"color": "^4.2.3",
"react-native-drawer-layout": "^4.2.5",
"react-native-drawer-layout": "^4.2.4",
"use-latest-callback": "^0.2.4"
},
"peerDependencies": {
"@react-navigation/native": "^7.3.1",
"@react-navigation/native": "^7.2.4",
"react": ">= 18.2.0",
"react-native": "*",
"react-native-gesture-handler": ">= 2.0.0",
@ -4880,9 +4847,9 @@
}
},
"node_modules/@react-navigation/elements": {
"version": "2.9.23",
"resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-2.9.23.tgz",
"integrity": "sha512-sp+FgihDyMBoEXoCUsUCT/iibN/sg6LYGq/rciy6NjT8bnfv4Cu3el8SAaJ0bfRG3tdchHy6gweKmcaJs/BAYQ==",
"version": "2.9.18",
"resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-2.9.18.tgz",
"integrity": "sha512-mKEvDr6CkCVYZSb8W9WubNseihL+1c8M7ktZJCTCbMk8rQgdQfkdRNwpSUQKspdGpUHCb9cyzvaiuzl1NtjVgw==",
"license": "MIT",
"dependencies": {
"color": "^4.2.3",
@ -4891,7 +4858,7 @@
},
"peerDependencies": {
"@react-native-masked-view/masked-view": ">= 0.2.0",
"@react-navigation/native": "^7.3.1",
"@react-navigation/native": "^7.2.4",
"react": ">= 18.2.0",
"react-native": "*",
"react-native-safe-area-context": ">= 4.0.0"
@ -4903,16 +4870,15 @@
}
},
"node_modules/@react-navigation/native": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.3.1.tgz",
"integrity": "sha512-g1o8jBm87WviR0Eq0wT0M43TSi+uBTz4x8YfHh4XRQ+FHqhNr+uGbuxtGu72QhHtOz0LWnb8UWyvd+M6xWkWHQ==",
"version": "7.2.4",
"resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.2.4.tgz",
"integrity": "sha512-eWC2D3JjhYLId2fVTZhhCiUpWIaPhO9XyEb7Wq8ElmOHyIODlbOzgZ0rKia02OIsDKr9BzZl2sK1dL70yMxDaw==",
"license": "MIT",
"dependencies": {
"@react-navigation/core": "^7.20.0",
"@react-navigation/core": "^7.17.4",
"escape-string-regexp": "^4.0.0",
"fast-deep-equal": "^3.1.3",
"nanoid": "^3.3.11",
"standard-navigation": "^0.0.7",
"use-latest-callback": "^0.2.4"
},
"peerDependencies": {
@ -4921,18 +4887,18 @@
}
},
"node_modules/@react-navigation/native-stack": {
"version": "7.17.3",
"resolved": "https://registry.npmjs.org/@react-navigation/native-stack/-/native-stack-7.17.3.tgz",
"integrity": "sha512-8X9AxW0BACB62eCL+DAL+Nf5lFAxXi3w1qaj2D/i0axYjxUZbI5AwrfuHjRo0B231K5WWa6HKyscF07IDHcKHg==",
"version": "7.15.1",
"resolved": "https://registry.npmjs.org/@react-navigation/native-stack/-/native-stack-7.15.1.tgz",
"integrity": "sha512-kNrJggwoB/onC0MpZIuZ6qaqeAziFchz+W9txBzhd6qbWmB1OkPVUnu6fWgc6BQc7MeMf59djVmqgX+6kJU1Ug==",
"license": "MIT",
"dependencies": {
"@react-navigation/elements": "^2.9.23",
"@react-navigation/elements": "^2.9.18",
"color": "^4.2.3",
"sf-symbols-typescript": "^2.1.0",
"warn-once": "^0.1.1"
},
"peerDependencies": {
"@react-navigation/native": "^7.3.1",
"@react-navigation/native": "^7.2.4",
"react": ">= 18.2.0",
"react-native": "*",
"react-native-safe-area-context": ">= 4.0.0",
@ -4940,9 +4906,9 @@
}
},
"node_modules/@react-navigation/routers": {
"version": "7.6.0",
"resolved": "https://registry.npmjs.org/@react-navigation/routers/-/routers-7.6.0.tgz",
"integrity": "sha512-lblhDXfS75jLc7G2K7BZGM+7cjqQXk13X/MA4fq/12r62zM+fBhhreLzYflSitrDDXFRJpSvJXy0ziiGU04Xow==",
"version": "7.5.5",
"resolved": "https://registry.npmjs.org/@react-navigation/routers/-/routers-7.5.5.tgz",
"integrity": "sha512-9/hhMte12Kgu+pMnLfA4EWJ0OQmIEAMVMX06FPH2yGkEQSQ3JhhCN/GkcRikzQhtEi97VYYQA15umptBUShcOQ==",
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11"
@ -5276,6 +5242,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 +7934,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",
@ -16284,9 +16259,9 @@
}
},
"node_modules/react-native-drawer-layout": {
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/react-native-drawer-layout/-/react-native-drawer-layout-4.2.5.tgz",
"integrity": "sha512-Yl82uLkXjXuq7222hWGIDsq5A6R/bsCeCEgdIxQUxAEHf00oRdDnRByLx3Fsij3qwtmYNPGrHV1NH8G8hbCbLQ==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/react-native-drawer-layout/-/react-native-drawer-layout-4.2.4.tgz",
"integrity": "sha512-l1Le5HcVidobnJm8xqFZo46Rs8FDHdxbTZhkjxpNSRgU+QMoQXilOfzTHAeNjEGiKVGgIs9cW3ctXeHqgp5jJg==",
"license": "MIT",
"dependencies": {
"color": "^4.2.3",
@ -16631,15 +16606,6 @@
"node": ">=10"
}
},
"node_modules/react-native/node_modules/@react-native/js-polyfills": {
"version": "0.85.3",
"resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.85.3.tgz",
"integrity": "sha512-U2+aMshIXf1uFn77tpBb/xhHWB9vkVrMpt7kkucAugF8hJKYTDGB587X7WwelHduK2KBfhl4giSv0rzZGoef9A==",
"license": "MIT",
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/react-native/node_modules/commander": {
"version": "12.1.0",
"license": "MIT",
@ -17890,12 +17856,6 @@
"node": ">=8"
}
},
"node_modules/standard-navigation": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/standard-navigation/-/standard-navigation-0.0.7.tgz",
"integrity": "sha512-NCGLCNyuXrFOkGHxdNZFnpsehGtiq1oXbPhKl7ZuxFO5J//H2evqqOchmD4YwEUJnkjO4kH9Xp4hQX6hdAYCKQ==",
"license": "MIT"
},
"node_modules/statuses": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz",

View File

@ -20,13 +20,14 @@
"@jest/reporters": "^27.5.1",
"@react-native/eslint-config": "^0.85.3",
"@react-native/jest-preset": "0.85.3",
"@react-native/js-polyfills": "^0.86.0",
"@react-native/js-polyfills": "^0.85.3",
"@react-native/metro-babel-transformer": "^0.85.3",
"@react-native/typescript-config": "^0.85.3",
"@testing-library/react-native": "^13.0.1",
"@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",
@ -118,10 +118,10 @@
"@react-native/codegen": "0.85.3",
"@react-native/gradle-plugin": "0.85.3",
"@react-native/metro-config": "0.85.3",
"@react-navigation/devtools": "7.0.62",
"@react-navigation/drawer": "7.12.0",
"@react-navigation/native": "7.3.1",
"@react-navigation/native-stack": "7.17.3",
"@react-navigation/devtools": "7.0.58",
"@react-navigation/drawer": "7.10.2",
"@react-navigation/native": "7.2.4",
"@react-navigation/native-stack": "7.15.1",
"@scure/base": "2.0.0",
"@spsina/bip47": "github:BlueWallet/bip47#df82345",
"aezeed": "0.0.5",
@ -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",

View File

@ -1,84 +0,0 @@
diff --git a/node_modules/@react-navigation/native-stack/lib/module/views/useHeaderConfigProps.js b/node_modules/@react-navigation/native-stack/lib/module/views/useHeaderConfigProps.js
index a42477a..3ff714c 100644
--- a/node_modules/@react-navigation/native-stack/lib/module/views/useHeaderConfigProps.js
+++ b/node_modules/@react-navigation/native-stack/lib/module/views/useHeaderConfigProps.js
@@ -159,7 +159,8 @@ export function useHeaderConfigProps({
route,
title,
unstable_headerLeftItems: headerLeftItems,
- unstable_headerRightItems: headerRightItems
+ unstable_headerRightItems: headerRightItems,
+ experimental_userInterfaceStyle: experimentalUserInterfaceStyleOption
}) {
const {
direction
@@ -365,7 +366,7 @@ export function useHeaderConfigProps({
children,
headerLeftBarButtonItems: processBarButtonItems(leftItems, colors, fonts),
headerRightBarButtonItems: processBarButtonItems(rightItems, colors, fonts),
- experimental_userInterfaceStyle: dark ? 'dark' : 'light'
+ experimental_userInterfaceStyle: experimentalUserInterfaceStyleOption ?? (dark ? 'dark' : 'light')
};
}
//# sourceMappingURL=useHeaderConfigProps.js.map
\ No newline at end of file
diff --git a/node_modules/@react-navigation/native-stack/lib/typescript/src/types.d.ts b/node_modules/@react-navigation/native-stack/lib/typescript/src/types.d.ts
index 2f1351a..5742b66 100644
--- a/node_modules/@react-navigation/native-stack/lib/typescript/src/types.d.ts
+++ b/node_modules/@react-navigation/native-stack/lib/typescript/src/types.d.ts
@@ -302,6 +302,14 @@ export type NativeStackNavigationOptions = {
* @platform ios
*/
unstable_headerRightItems?: (props: NativeStackHeaderItemProps) => NativeStackHeaderItem[];
+ /**
+ * When set, overrides the navigation header `UIUserInterfaceStyle` (affects iOS 26+ bar materials and tint resolution).
+ * If omitted, React Navigation sets this from the navigation theme `dark` boolean (`true` → `"dark"`, else `"light"`).
+ *
+ * @platform ios
+ * @experimental
+ */
+ experimental_userInterfaceStyle?: import('react-native-screens').ScreenStackHeaderConfigProps['experimental_userInterfaceStyle'];
/**
* String or a function that returns a React Element to be used by the header.
* Defaults to screen `title` or route name.
diff --git a/node_modules/@react-navigation/native-stack/src/types.tsx b/node_modules/@react-navigation/native-stack/src/types.tsx
index 7488b1c..542333e 100644
--- a/node_modules/@react-navigation/native-stack/src/types.tsx
+++ b/node_modules/@react-navigation/native-stack/src/types.tsx
@@ -350,6 +350,15 @@ export type NativeStackNavigationOptions = {
unstable_headerRightItems?: (
props: NativeStackHeaderItemProps
) => NativeStackHeaderItem[];
+ /**
+ * When set, overrides the navigation header `UIUserInterfaceStyle` (affects iOS 26+ bar materials and tint resolution).
+ * If omitted, React Navigation sets this from the navigation theme `dark` boolean (`true` → `"dark"`, else `"light"`).
+ *
+ * @platform ios
+ * @experimental
+ * @see {@link https://github.com/react-navigation/react-navigation/issues/13069}
+ */
+ experimental_userInterfaceStyle?: ScreenStackHeaderConfigProps['experimental_userInterfaceStyle'];
/**
* String or a function that returns a React Element to be used by the header.
* Defaults to screen `title` or route name.
diff --git a/node_modules/@react-navigation/native-stack/src/views/useHeaderConfigProps.tsx b/node_modules/@react-navigation/native-stack/src/views/useHeaderConfigProps.tsx
index 6f74856..d12cf7d 100644
--- a/node_modules/@react-navigation/native-stack/src/views/useHeaderConfigProps.tsx
+++ b/node_modules/@react-navigation/native-stack/src/views/useHeaderConfigProps.tsx
@@ -217,6 +217,7 @@ export function useHeaderConfigProps({
title,
unstable_headerLeftItems: headerLeftItems,
unstable_headerRightItems: headerRightItems,
+ experimental_userInterfaceStyle: experimentalUserInterfaceStyleOption,
}: Props): ScreenStackHeaderConfigProps {
const { direction } = useLocale();
const { colors, fonts, dark } = useTheme();
@@ -527,6 +528,7 @@ export function useHeaderConfigProps({
children,
headerLeftBarButtonItems: processBarButtonItems(leftItems, colors, fonts),
headerRightBarButtonItems: processBarButtonItems(rightItems, colors, fonts),
- experimental_userInterfaceStyle: dark ? 'dark' : 'light',
+ experimental_userInterfaceStyle:
+ experimentalUserInterfaceStyleOption ?? (dark ? 'dark' : 'light'),
} as const;
}

View File

@ -64,48 +64,3 @@ delivered.
Added in BlueWallet PR https://github.com/BlueWallet/BlueWallet/pull/8424
during a React Native bump. Remove once `react-native-notifications`
ships New-Architecture-safe token delivery.
---
## `@react-navigation+native-stack+7.15.1.patch`
**What:** adds an `experimental_userInterfaceStyle` navigation option to
`NativeStackNavigationOptions` (typed in `src/types.tsx` and the built
`lib/typescript` d.ts) and threads it through `useHeaderConfigProps` so a
screen can override the header's `UIUserInterfaceStyle`. When omitted it
falls back to the previous behaviour via
`experimentalUserInterfaceStyleOption ?? (dark ? 'dark' : 'light')`.
**Why:** on iOS 26 the navigation bar's liquid-glass material and tint are
resolved from `UIUserInterfaceStyle`. React Navigation hard-codes this from
the theme `dark` boolean, so a screen cannot force a light/dark header
independent of the active theme. The iOS 26 glass header
(`screen/wallets/WalletTransactions.tsx`) needs that per-screen override.
**Upstream:** https://github.com/react-navigation/react-navigation/issues/13069 (open)
Added in BlueWallet PR https://github.com/BlueWallet/BlueWallet/pull/8508.
Remove once `@react-navigation/native-stack` exposes a header
`UIUserInterfaceStyle` override upstream. When bumping the dependency,
rename this patch to the new version and re-confirm the hunks still apply
(`npx patch-package`).
---
## `react-native-screens+4.25.2.patch`
**What:** in `RNSBarButtonItem.mm`, also set `self.accessibilityIdentifier`
when the JS `identifier` is provided (one line, alongside the existing
`self.identifier = identifier`).
**Why:** the iOS 26 glass header builds nav-bar buttons through
`unstable_headerRightItems`. The native `identifier` is not exposed as an
accessibility identifier, so Detox/XCUITest could not target those bar
buttons. Mirroring it onto `accessibilityIdentifier` makes them reachable
from e2e tests.
**Upstream:** no issue filed yet — local accessibility enhancement.
Added in BlueWallet PR https://github.com/BlueWallet/BlueWallet/pull/8508.
When bumping `react-native-screens`, rename this patch to the new version
and re-confirm the hunk still applies (`npx patch-package`).

View File

@ -1,12 +0,0 @@
diff --git a/node_modules/react-native-screens/ios/RNSBarButtonItem.mm b/node_modules/react-native-screens/ios/RNSBarButtonItem.mm
index 0eb1f09..324b888 100644
--- a/node_modules/react-native-screens/ios/RNSBarButtonItem.mm
+++ b/node_modules/react-native-screens/ios/RNSBarButtonItem.mm
@@ -81,6 +81,7 @@ - (instancetype)initWithConfig:(NSDictionary<NSString *, id> *)dict
NSString *identifier = dict[@"identifier"];
if (identifier != nil) {
self.identifier = identifier;
+ self.accessibilityIdentifier = identifier;
}
NSDictionary *badgeConfig = dict[@"badge"];
if (badgeConfig != nil) {

View File

@ -1,29 +1,8 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:best-practices",
":disableMajorUpdates",
":preserveSemverRanges"
],
"ignoreDeps": ["react-native"],
"schedule": ["before 6am on monday"],
"prConcurrentLimit": 1,
"prHourlyLimit": 0,
"minimumReleaseAge": "3 days",
"semanticCommits": "disabled",
"commitMessagePrefix": "OPS:",
"packageRules": [
{
"matchPackageNames": ["*"],
"groupName": "all dependencies",
"groupSlug": "all"
}
],
"lockFileMaintenance": {
"enabled": true,
"schedule": ["before 6am on monday"]
},
"vulnerabilityAlerts": {
"schedule": ["at any time"]
}
"ignoreDeps": ["react-native"]
}

View File

@ -2,13 +2,7 @@ import React, { useMemo, useLayoutEffect, useCallback } from 'react';
import { View, StyleSheet, Linking, Image, Platform } from 'react-native';
import { useExtendedNavigation } from '../../hooks/useExtendedNavigation';
import loc from '../../loc';
import {
SettingsScrollView,
SettingsSection,
SettingsListItem,
getSettingsHeaderOptions,
isIOS26OrHigher,
} from '../../components/platform';
import { SettingsScrollView, SettingsSection, SettingsListItem, getSettingsHeaderOptions } from '../../components/platform';
import { useSettings } from '../../hooks/context/useSettings';
import { useTheme } from '../../components/themes';
@ -21,9 +15,6 @@ const Settings = () => {
const settingsScreenBackgroundColor = isIOSLightMode ? settingsCardColor : colors.background;
const settingsListItemBackgroundColor = isIOSLightMode ? colors.background : undefined;
useLayoutEffect(() => {
if (isIOS26OrHigher) {
return;
}
setOptions(getSettingsHeaderOptions(loc.settings.header, { ...colors, background: settingsScreenBackgroundColor }, dark));
}, [setOptions, language, colors, settingsScreenBackgroundColor, dark]); // Include language to trigger re-render when language changes

View File

@ -2,7 +2,7 @@ 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 relativeTime from 'dayjs/plugin/relativeTime';
@ -63,10 +63,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 +136,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,57 +153,10 @@ 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]);
@ -976,20 +921,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 +962,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))}
@ -1061,10 +996,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>
@ -1096,11 +1029,9 @@ const TransactionStatus: React.FC = () => {
<View style={styles.stateIndicator}>
<TransactionOutgoingIcon />
<View style={styles.stateLabelContainer}>
<BlueText style={[styles.stateLabel, stylesHook.stateLabelSent, scaledStyles.stateLabel]}>
{loc.transactions.details_sent}
</BlueText>
<BlueText style={[styles.stateLabel, stylesHook.stateLabelSent]}>{loc.transactions.details_sent}</BlueText>
{isOnChainTx && (
<BlueText style={[styles.stateValue, stylesHook.stateValueSent, styles.stateValueInline, scaledStyles.stateValue]}>
<BlueText style={[styles.stateValue, stylesHook.stateValueSent, styles.stateValueInline]}>
{loc.formatString(loc.transactions.confirmations_lowercase, {
confirmations: parsedConfirmations > 6 ? '6+' : parsedConfirmations,
})}
@ -1112,11 +1043,9 @@ const TransactionStatus: React.FC = () => {
<View style={styles.stateIndicator}>
<TransactionIncomingIcon />
<View style={styles.stateLabelContainer}>
<BlueText style={[styles.stateLabel, stylesHook.stateLabelReceived, scaledStyles.stateLabel]}>
{loc.transactions.details_received}
</BlueText>
<BlueText style={[styles.stateLabel, stylesHook.stateLabelReceived]}>{loc.transactions.details_received}</BlueText>
{isOnChainTx && (
<BlueText style={[styles.stateValue, stylesHook.stateValueReceived, styles.stateValueInline, scaledStyles.stateValue]}>
<BlueText style={[styles.stateValue, stylesHook.stateValueReceived, styles.stateValueInline]}>
{loc.formatString(loc.transactions.confirmations_lowercase, {
confirmations: parsedConfirmations > 6 ? '6+' : parsedConfirmations,
})}
@ -1151,29 +1080,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 +1117,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 +1143,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 +1170,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 +1180,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>
@ -1283,13 +1192,11 @@ const TransactionStatus: React.FC = () => {
<View style={[styles.detailsCard, stylesHook.detailsCard]}>
<TouchableOpacity
onPress={() => setIsAdvancedExpanded(!isAdvancedExpanded)}
style={[styles.advancedHeader, stylesHook.advancedHeader, scaledStyles.advancedHeader]}
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 +1209,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 +1221,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 +1233,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 +1245,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 +1310,6 @@ const styles = StyleSheet.create({
alignItems: 'flex-start',
justifyContent: 'center',
flex: 1,
minWidth: 0,
},
headerTitleDirection: {
fontSize: 17,
@ -1451,20 +1357,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 +1383,7 @@ const styles = StyleSheet.create({
borderRadius: 12,
marginHorizontal: 24,
marginBottom: 42,
overflow: 'hidden',
},
stateSection: {
alignItems: 'flex-start',
@ -1499,7 +1401,6 @@ const styles = StyleSheet.create({
alignItems: 'flex-start',
marginLeft: 8,
flex: 1,
minWidth: 0,
},
stateLabel: {
fontSize: 16,
@ -1585,23 +1486,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 +1507,7 @@ const styles = StyleSheet.create({
detailRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
alignItems: 'center',
marginBottom: 0,
minHeight: 24,
paddingVertical: 12,
@ -1636,8 +1531,6 @@ const styles = StyleSheet.create({
fontSize: 16,
fontWeight: '500',
flex: 1,
flexShrink: 1,
minWidth: 0,
lineHeight: 22,
paddingRight: 12,
},
@ -1651,12 +1544,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 +1596,7 @@ const styles = StyleSheet.create({
paddingHorizontal: 12,
borderRadius: 6,
alignSelf: 'flex-end',
flexShrink: 0,
minWidth: 50,
alignItems: 'center',
justifyContent: 'center',
},
@ -1722,6 +1614,7 @@ const styles = StyleSheet.create({
borderWidth: 1,
borderTopLeftRadius: 12,
borderTopRightRadius: 12,
overflow: 'hidden',
},
advancedContent: {
marginTop: 0,

View File

@ -11,15 +11,9 @@ import {
ScrollView,
StyleSheet,
Text,
useWindowDimensions,
View,
RefreshControl,
NativeScrollEvent,
NativeSyntheticEvent,
StyleProp,
ViewStyle,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import Icon from '../../components/Icon';
import * as BlueElectrum from '../../blue_modules/BlueElectrum';
import { isDesktop } from '../../blue_modules/environment';
@ -33,7 +27,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';
@ -42,14 +35,10 @@ import { Chain } from '../../models/bitcoinUnits';
import ActionSheet from '../ActionSheet';
import { useStorage } from '../../hooks/context/useStorage';
import WatchOnlyWarning from '../../components/WatchOnlyWarning';
import { NativeStackNavigationOptions, NativeStackScreenProps } from '@react-navigation/native-stack';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { DetailViewStackParamList } from '../../navigation/DetailViewStackParamList';
import { Transaction, TWallet } from '../../class/wallets/types';
import getWalletTransactionsOptions, {
WalletTransactionsRouteProps,
createWalletDetailsHeaderRight,
createWalletDetailsHeaderRightItems,
} from '../../navigation/helpers/getWalletTransactionsOptions';
import getWalletTransactionsOptions, { WalletTransactionsRouteProps } from '../../navigation/helpers/getWalletTransactionsOptions';
import { presentWalletExportReminder } from '../../helpers/presentWalletExportReminder';
import selectWallet from '../../helpers/select-wallet';
import assert from 'assert';
@ -60,8 +49,6 @@ import { getClipboardContent } from '../../blue_modules/clipboard';
import HandOffComponent from '../../components/HandOffComponent';
import { HandOffActivityType } from '../../components/types';
import WalletGradient from '../../class/wallet-gradient';
import { isIOS26OrHigher } from '../../components/platform';
import Animated, { SharedValue, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
const buttonFontSize =
PixelRatio.roundToNearestPixel(Dimensions.get('window').width / 26) > 22
@ -72,109 +59,7 @@ type RouteProps = RouteProp<DetailViewStackParamList, 'WalletTransactions'>;
type WalletTransactionsProps = NativeStackScreenProps<DetailViewStackParamList, 'WalletTransactions'>;
/** Scroll offset after which the compact wallet name + balance header is shown. */
const SCROLLED_HEADER_SHOW_OFFSET = 180;
const SCROLLED_HEADER_FADE_IN_MS = 180;
const SCROLLED_HEADER_FADE_OUT_MS = 150;
const usesIos26AnimatedScrolledHeader = Platform.OS === 'ios' && isIOS26OrHigher && !isDesktop;
/** Native stack options used when scrolled; includes props missing from the published TS types. */
type WalletTransactionsScrolledHeaderOptions = NativeStackNavigationOptions & {
headerTitleContainerStyle?: StyleProp<ViewStyle>;
};
/** Horizontal space reserved so the scrolled title does not run under back / header-right actions. */
const getScrolledHeaderTitleLayout = (screenWidth: number) => {
const titleInsetLeft = Platform.OS === 'ios' ? (isIOS26OrHigher ? 40 : 56) : 72;
const titleInsetRight = Platform.OS === 'ios' ? (isIOS26OrHigher ? 96 : 84) : 84;
return {
maxWidth: Math.max(0, screenWidth - titleInsetLeft - titleInsetRight),
titleInsetLeft,
titleInsetRight,
};
};
const buildIos26HeaderTitleLayoutOptions = (
screenWidth: number,
): Pick<WalletTransactionsScrolledHeaderOptions, 'headerTitleAlign' | 'headerTitleContainerStyle'> => ({
headerTitleAlign: 'left',
headerTitleContainerStyle: {
width: screenWidth,
maxWidth: screenWidth,
alignSelf: 'flex-start',
alignItems: 'flex-start',
left: 0,
flexShrink: 1,
minWidth: 0,
},
});
type WalletTransactionsScrolledHeaderTitleProps = {
walletLabel: string;
balance: string;
};
type WalletTransactionsScrolledHeaderTitleAnimatedProps = WalletTransactionsScrolledHeaderTitleProps & {
opacity: SharedValue<number>;
};
const WalletTransactionsScrolledHeaderTitleAnimated: React.FC<WalletTransactionsScrolledHeaderTitleAnimatedProps> = ({
opacity,
walletLabel,
balance,
}) => {
const { width: screenWidth } = useWindowDimensions();
const animatedStyle = useAnimatedStyle(() => ({
opacity: opacity.value,
}));
return (
<Animated.View style={[scrolledHeaderTitleStyles.animatedTitleWrapper, { width: screenWidth }, animatedStyle]} pointerEvents="box-none">
<WalletTransactionsScrolledHeaderTitle walletLabel={walletLabel} balance={balance} />
</Animated.View>
);
};
const WalletTransactionsScrolledHeaderTitle: React.FC<WalletTransactionsScrolledHeaderTitleProps> = ({ walletLabel, balance }) => {
const { width: screenWidth } = useWindowDimensions();
const { colors } = useTheme();
const { maxWidth, titleInsetLeft, titleInsetRight } = getScrolledHeaderTitleLayout(screenWidth);
const titleColor = Platform.OS === 'ios' ? colors.foregroundColor : '#FFFFFF';
const titleContent = (
<>
<Text style={[scrolledHeaderTitleStyles.walletLabel, { color: titleColor }]} numberOfLines={1} ellipsizeMode="tail">
{walletLabel}
</Text>
{balance.length > 0 ? (
<Text style={[scrolledHeaderTitleStyles.balance, { color: titleColor }]} numberOfLines={1} ellipsizeMode="tail">
{balance}
</Text>
) : null}
</>
);
if (Platform.OS === 'ios') {
return (
<View style={[scrolledHeaderTitleStyles.iosHeaderRoot, { width: screenWidth }]}>
<View
style={[
scrolledHeaderTitleStyles.container,
scrolledHeaderTitleStyles.iosTitleArea,
{ left: titleInsetLeft, right: titleInsetRight },
]}
>
{titleContent}
</View>
</View>
);
}
return <View style={[scrolledHeaderTitleStyles.container, { maxWidth }]}>{titleContent}</View>;
};
type TransactionListItem = Transaction & { type: 'transaction' | 'header' };
const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { route: WalletTransactionsRouteProps }) => {
const { wallets, saveToDisk } = useStorage();
const { registerTransactionsHandler, unregisterTransactionsHandler } = useMenuElements();
@ -188,11 +73,8 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
const [pageSize] = useState(20);
const navigation = useExtendedNavigation();
const { setOptions, navigate } = navigation;
const { colors, dark } = useTheme();
const { colors } = useTheme();
const { isElectrumDisabled } = useSettings();
const insets = useSafeAreaInsets();
const navBarHeight = Platform.select({ ios: 44, android: 56, default: 44 }) ?? 44;
const headerOverlayHeight = insets.top + navBarHeight;
const walletActionButtonsRef = useRef<View>(null);
const [lastFetchTimestamp, setLastFetchTimestamp] = useState(() => wallet._lastTxFetch || 0);
const [fetchFailures, setFetchFailures] = useState(0);
@ -205,8 +87,7 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
const MAX_FAILURES = 3;
const flatListRef = useRef<FlatList<Transaction>>(null);
const headerRef = useRef<View>(null);
const headerScrolledRef = useRef(false);
const scrolledHeaderOpacity = useSharedValue(0);
const [headerHeight, setHeaderHeight] = useState(0);
const stylesHook = StyleSheet.create({
listHeaderText: {
@ -219,17 +100,44 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
backgroundContainer: {
backgroundColor: colors.background,
},
gradientBackground: {
backgroundColor: WalletGradient.headerColorFor(wallet.type),
height: headerHeight > 0 ? headerHeight : '30%',
},
activityIndicatorStyle: {
backgroundColor: colors.background,
},
sendIcon: {
transform: [{ rotate: direction === 'rtl' ? '-225deg' : '225deg' }],
},
receiveIcon: {
transform: [{ rotate: direction === 'rtl' ? '-45deg' : '45deg' }],
sendIcon: { transform: [{ rotate: direction === 'rtl' ? '-225deg' : '225deg' }] },
receiveIcon: { transform: [{ rotate: direction === 'rtl' ? '-45deg' : '45deg' }] },
headerBottomBar: {
position: 'absolute',
left: 0,
right: 0,
bottom: 12,
height: 12,
backgroundColor: colors.background,
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
...Platform.select({
ios: {
shadowColor: colors.shadowColor,
shadowOffset: { width: 0, height: -8 },
shadowOpacity: 0.1,
shadowRadius: 6,
},
android: {
elevation: 0.5,
},
}),
},
});
useFocusEffect(
useCallback(() => {
setOptions(getWalletTransactionsOptions({ route }));
}, [route, setOptions]),
);
const onBarCodeRead = useCallback(
(ret?: { data?: any }) => {
if (!isLoading) {
@ -239,15 +147,9 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
uri: ret?.data ? ret.data : ret,
};
if (wallet.chain === Chain.ONCHAIN) {
navigate('SendDetailsRoot', {
screen: 'SendDetails',
params: parameters,
});
navigate('SendDetailsRoot', { screen: 'SendDetails', params: parameters });
} else {
navigate('ScanLNDInvoiceRoot', {
screen: 'ScanLNDInvoice',
params: parameters,
});
navigate('ScanLNDInvoiceRoot', { screen: 'ScanLNDInvoice', params: parameters });
}
setIsLoading(false);
}
@ -265,6 +167,7 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
useEffect(() => {
// keep local display unit in sync when wallet changes (e.g., switching wallets)
console.debug('[UnitSwitch] sync from wallet preferred unit', { walletID, preferred: wallet.preferredBalanceUnit });
setDisplayUnit(wallet.preferredBalanceUnit);
}, [wallet, walletID]);
@ -273,6 +176,10 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [walletID]);
useEffect(() => {
console.debug('[UnitSwitch] display unit state changed', { walletID, displayUnit, switching: isUnitSwitching });
}, [walletID, displayUnit, isUnitSwitching]);
const sortedTransactions = useMemo(() => {
const txs = wallet.getTransactions();
txs.sort((a, b) => b.timestamp - a.timestamp);
@ -396,10 +303,7 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
await wallet.fetchBtcAddress();
toAddress = wallet.refill_addressess[0];
} catch (Err) {
return presentAlert({
message: (Err as Error).message,
type: AlertType.Toast,
});
return presentAlert({ message: (Err as Error).message, type: AlertType.Toast });
}
}
@ -438,17 +342,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
@ -493,10 +391,7 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
const sendButtonPress = () => {
if (wallet.chain === Chain.OFFCHAIN) {
return navigate('ScanLNDInvoiceRoot', {
screen: 'ScanLNDInvoice',
params: { walletID },
});
return navigate('ScanLNDInvoiceRoot', { screen: 'ScanLNDInvoice', params: { walletID } });
}
if (wallet.type === WatchOnlyWallet.type && wallet.isHd() && !wallet.useWithHardwareWalletEnabled()) {
@ -598,136 +493,55 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [wallet, wallet.hideBalance, displayUnit, balance]);
const walletLabel = wallet.getLabel();
const scrolledHeaderTitle = useCallback(() => {
if (usesIos26AnimatedScrolledHeader) {
return (
<WalletTransactionsScrolledHeaderTitleAnimated opacity={scrolledHeaderOpacity} walletLabel={walletLabel} balance={walletBalance} />
);
}
return <WalletTransactionsScrolledHeaderTitle walletLabel={walletLabel} balance={walletBalance} />;
}, [walletLabel, walletBalance, scrolledHeaderOpacity]);
const { width: screenWidth } = useWindowDimensions();
const getScrolledHeaderOptions = useCallback((): WalletTransactionsScrolledHeaderOptions => {
const { titleInsetRight } = getScrolledHeaderTitleLayout(screenWidth);
const routeIsLoading = route.params.isLoading ?? false;
const scrolledHeaderIconColor = colors.foregroundColor;
return {
headerTitle: scrolledHeaderTitle,
// iOS ignores 'left'; title is positioned manually in WalletTransactionsScrolledHeaderTitle.
...(Platform.OS === 'ios'
? buildIos26HeaderTitleLayoutOptions(screenWidth)
: {
headerTitleAlign: 'left' as const,
headerTitleContainerStyle: {
paddingRight: titleInsetRight,
flexShrink: 1,
minWidth: 0,
alignItems: 'flex-start',
},
headerStyle: {
backgroundColor: WalletGradient.headerColorFor(wallet.type),
},
headerTintColor: '#ffffff',
}),
...(Platform.OS === 'ios'
? {
headerTintColor: scrolledHeaderIconColor,
statusBarStyle: 'light',
...(isIOS26OrHigher && !isDesktop
? {
headerRight: undefined,
unstable_headerRightItems: createWalletDetailsHeaderRightItems({
isLoading: routeIsLoading,
walletID,
}),
experimental_userInterfaceStyle: dark ? ('dark' as const) : ('light' as const),
}
: {
headerBlurEffect: dark ? ('dark' as const) : ('light' as const),
headerRight: createWalletDetailsHeaderRight({
walletID,
isLoading: routeIsLoading,
iconColor: scrolledHeaderIconColor,
}),
}),
}
: {}),
};
}, [scrolledHeaderTitle, screenWidth, colors.foregroundColor, dark, route.params.isLoading, walletID, wallet.type]);
useEffect(() => {
if (!headerScrolledRef.current) return;
setOptions(getScrolledHeaderOptions());
}, [walletBalance, getScrolledHeaderOptions, setOptions]);
useFocusEffect(
useCallback(() => {
if (usesIos26AnimatedScrolledHeader) {
headerScrolledRef.current = false;
scrolledHeaderOpacity.value = 0;
setOptions({
...getWalletTransactionsOptions({ route }),
...buildIos26HeaderTitleLayoutOptions(screenWidth),
headerTitle: scrolledHeaderTitle,
});
return;
}
setOptions(getWalletTransactionsOptions({ route }));
}, [route, screenWidth, scrolledHeaderTitle, scrolledHeaderOpacity, setOptions]),
);
const handleScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
(event: any) => {
const offsetY = event.nativeEvent.contentOffset.y;
const scrolled = offsetY >= SCROLLED_HEADER_SHOW_OFFSET;
if (usesIos26AnimatedScrolledHeader) {
if (scrolled === headerScrolledRef.current) return;
headerScrolledRef.current = scrolled;
scrolledHeaderOpacity.value = withTiming(scrolled ? 1 : 0, {
duration: scrolled ? SCROLLED_HEADER_FADE_IN_MS : SCROLLED_HEADER_FADE_OUT_MS,
});
if (scrolled) {
setOptions(getScrolledHeaderOptions());
} else {
setOptions({
...getWalletTransactionsOptions({ route }),
...buildIos26HeaderTitleLayoutOptions(screenWidth),
headerTitle: scrolledHeaderTitle,
});
}
return;
}
if (scrolled === headerScrolledRef.current) return;
headerScrolledRef.current = scrolled;
if (!scrolled) {
setOptions({
...getWalletTransactionsOptions({ route }),
headerTitle: undefined,
headerTitleAlign: undefined,
headerTitleContainerStyle: undefined,
headerBlurEffect: undefined,
});
const combinedHeight = 180;
if (offsetY < combinedHeight) {
setOptions({ ...getWalletTransactionsOptions({ route }), headerTitle: undefined });
} else {
setOptions(getScrolledHeaderOptions());
navigation.setOptions({
headerTitle: `${wallet.getLabel()} ${walletBalance}`,
});
}
},
[getScrolledHeaderOptions, setOptions, route, screenWidth, scrolledHeaderTitle, scrolledHeaderOpacity],
[navigation, wallet, walletBalance, setOptions, route],
);
const ListHeaderComponent = useCallback(
const measureHeaderHeight = useCallback(() => {
if (!headerRef.current) {
// If header ref is not available, use default background
setHeaderHeight(0);
return;
}
headerRef.current.measure((x, y, width, height, pageX, pageY) => {
// Check if the header is actually visible
if (height === 0 || pageY < 0) {
// Header is not visible, use default background
setHeaderHeight(0);
return;
}
const fullHeight = pageY + height;
if (fullHeight > 0) {
setHeaderHeight(fullHeight);
}
});
}, []);
useEffect(() => {
const timer = setTimeout(measureHeaderHeight, 100);
return () => clearTimeout(timer);
}, [walletID, measureHeaderHeight]);
const ListHeaderComponent = useMemo(
() => (
<View ref={headerRef}>
<View ref={headerRef} onLayout={measureHeaderHeight}>
<TransactionsNavigationHeader
headerOverlayHeight={headerOverlayHeight}
wallet={wallet}
onWalletUnitChange={async selectedUnit => {
console.debug('[UnitSwitch] requested', { walletID, from: displayUnit, to: selectedUnit });
setIsUnitSwitching(true);
setDisplayUnit(selectedUnit);
if ('setPreferredBalanceUnit' in wallet) {
@ -736,25 +550,22 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
(wallet as TWallet).preferredBalanceUnit = selectedUnit;
}
await saveToDisk();
console.debug('[UnitSwitch] persisted preferred unit', { walletID, unit: selectedUnit });
setTimeout(() => {
setIsUnitSwitching(false);
console.debug('[UnitSwitch] complete', { walletID, unit: selectedUnit });
}, 50);
}}
unit={displayUnit}
unitSwitching={isUnitSwitching}
onWalletBalanceVisibilityChange={async shouldHideBalance => {
try {
const isBiometricsEnabled = await isBiometricUseCapableAndEnabled();
if (wallet.hideBalance && !shouldHideBalance && isBiometricsEnabled) {
if (!(await unlockWithBiometrics())) {
return;
}
}
wallet.hideBalance = shouldHideBalance;
await saveToDisk();
} catch (error) {
console.error('Failed to toggle balance visibility:', error);
onWalletBalanceVisibilityChange={async isShouldBeVisible => {
const isBiometricsEnabled = await isBiometricUseCapableAndEnabled();
if (wallet.hideBalance && isBiometricsEnabled) {
const unlocked = await unlockWithBiometrics();
if (!unlocked) throw new Error('Biometrics failed');
}
wallet.hideBalance = isShouldBeVisible;
await saveToDisk();
}}
onManageFundsPressed={id => {
if (wallet.type === MultisigHDWallet.type) {
@ -780,30 +591,36 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
}
}}
/>
<View style={[styles.flex, styles.transactionsSection, stylesHook.backgroundContainer]}>
<View style={styles.listHeaderTextRow}>
<Text style={[styles.listHeaderText, stylesHook.listHeaderText]}>{loc.transactions.list_title}</Text>
<View style={styles.headerBottomBarSpacer}>
<View style={stylesHook.headerBottomBar} />
</View>
<>
<View style={[styles.flex, stylesHook.backgroundContainer]}>
<View style={styles.listHeaderTextRow}>
<Text style={[styles.listHeaderText, stylesHook.listHeaderText]}>{loc.transactions.list_title}</Text>
</View>
</View>
</View>
<View style={stylesHook.backgroundContainer}>
{wallet.type === WatchOnlyWallet.type && isWatchOnlyWarningVisible && (
<WatchOnlyWarning
handleDismiss={() => {
setIsWatchOnlyWarningVisible(false);
wallet.isWatchOnlyWarningVisible = false;
saveToDisk();
}}
/>
)}
</View>
<View style={stylesHook.backgroundContainer}>
{wallet.type === WatchOnlyWallet.type && isWatchOnlyWarningVisible && (
<WatchOnlyWarning
handleDismiss={() => {
setIsWatchOnlyWarningVisible(false);
wallet.isWatchOnlyWarningVisible = false;
saveToDisk();
}}
/>
)}
</View>
</>
</View>
),
[
wallet,
displayUnit,
isUnitSwitching,
headerOverlayHeight,
measureHeaderHeight,
stylesHook.backgroundContainer,
stylesHook.headerBottomBar,
stylesHook.listHeaderText,
saveToDisk,
isBiometricUseCapableAndEnabled,
@ -816,18 +633,16 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
);
useEffect(() => {
headerScrolledRef.current = false;
scrolledHeaderOpacity.value = 0;
if (flatListRef.current) {
flatListRef.current.scrollToOffset({ offset: 0, animated: true });
}
}, [walletID, scrolledHeaderOpacity]);
}, [walletID]);
return (
<View style={[styles.flex, { backgroundColor: WalletGradient.headerColorFor(wallet.type) }]} testID="TransactionsListView">
<View style={[styles.flex, stylesHook.backgroundContainer]}>
<View style={[styles.refreshIndicatorBackground, stylesHook.gradientBackground]} testID="TransactionsListView" />
<FlatList<Transaction>
ref={flatListRef}
style={styles.flatList}
getItemLayout={getItemLayout}
updateCellsBatchingPeriod={50}
onEndReachedThreshold={0.3}
@ -838,9 +653,8 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
keyExtractor={_keyExtractor}
renderItem={renderItem}
initialNumToRender={10}
removeClippedSubviews={false}
contentContainerStyle={[styles.contentContainer, stylesHook.backgroundContainer]}
contentInsetAdjustmentBehavior="never"
removeClippedSubviews
contentContainerStyle={stylesHook.backgroundContainer}
contentInset={{ top: 0, left: 0, bottom: 90, right: 0 }}
maxToRenderPerBatch={10}
onScroll={handleScroll}
@ -857,25 +671,11 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
}
refreshControl={
!isDesktop && !isElectrumDisabled ? (
<RefreshControl
refreshing={isLoading}
onRefresh={() => refreshTransactions(true)}
tintColor={Platform.OS === 'ios' ? 'transparent' : colors.msSuccessCheck}
progressViewOffset={headerOverlayHeight}
/>
<RefreshControl refreshing={isLoading} onRefresh={() => refreshTransactions(true)} tintColor={colors.msSuccessCheck} />
) : undefined
}
/>
{isLoading && Platform.OS === 'ios' && (
<ActivityIndicator
style={[styles.refreshSpinner, { top: headerOverlayHeight + 12, transform: [{ scale: 1.4 }] }]}
color="#ffffff"
size="small"
pointerEvents="none"
/>
)}
<FloatButtonsBottomFade />
<FContainer ref={walletActionButtonsRef}>
{wallet.allowReceive() && (
@ -884,10 +684,7 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
text={loc.receive.header}
onPress={() => {
if (wallet.chain === Chain.OFFCHAIN) {
navigate('LNDCreateInvoiceRoot', {
screen: 'LNDCreateInvoice',
params: { walletID },
});
navigate('LNDCreateInvoiceRoot', { screen: 'LNDCreateInvoice', params: { walletID } });
} else {
navigate('ReceiveDetails', { walletID });
}
@ -938,81 +735,22 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
export default WalletTransactions;
const scrolledHeaderTitleStyles = StyleSheet.create({
animatedTitleWrapper: {
alignSelf: 'flex-start',
},
iosHeaderRoot: {
height: 44,
justifyContent: 'center',
},
iosTitleArea: {
position: 'absolute',
top: 0,
bottom: 0,
minWidth: 0,
},
container: {
minWidth: 0,
alignItems: 'flex-start',
justifyContent: 'center',
overflow: 'hidden',
},
walletLabel: {
fontSize: 17,
fontWeight: '600',
letterSpacing: 0.15,
alignSelf: 'stretch',
flexShrink: 1,
},
balance: {
fontSize: 13,
fontWeight: '500',
lineHeight: 18,
marginTop: 1,
alignSelf: 'stretch',
flexShrink: 1,
},
});
const styles = StyleSheet.create({
flex: { flex: 1 },
flatList: { flex: 1, backgroundColor: 'transparent' },
transactionsSection: { marginTop: -1 },
scrollViewContent: {
flex: 1,
justifyContent: 'center',
paddingHorizontal: 16,
paddingBottom: 500,
},
headerBottomBarSpacer: { position: 'relative', height: 12 },
scrollViewContent: { flex: 1, justifyContent: 'center', paddingHorizontal: 16, paddingBottom: 500 },
activityIndicator: { marginVertical: 20 },
listHeaderTextRow: {
flex: 1,
marginHorizontal: 16,
flexDirection: 'row',
justifyContent: 'space-between',
listHeaderTextRow: { flex: 1, marginHorizontal: 16, flexDirection: 'row', justifyContent: 'space-between' },
listHeaderText: { marginTop: 0, marginBottom: 16, fontWeight: 'bold', fontSize: 24 },
refreshIndicatorBackground: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
},
listHeaderText: {
marginTop: 16,
marginBottom: 16,
fontWeight: 'bold',
fontSize: 24,
},
contentContainer: { flexGrow: 1 },
refreshSpinner: { position: 'absolute', alignSelf: 'center', zIndex: 10 },
emptyTxsContainer: { height: '10%', minHeight: '10%', flex: 1 },
emptyTxs: {
fontSize: 18,
color: '#9aa0aa',
textAlign: 'center',
marginVertical: 16,
},
emptyTxsLightning: {
fontSize: 18,
color: '#9aa0aa',
textAlign: 'center',
fontWeight: '600',
},
emptyTxs: { fontSize: 18, color: '#9aa0aa', textAlign: 'center', marginVertical: 16 },
emptyTxsLightning: { fontSize: 18, color: '#9aa0aa', textAlign: 'center', fontWeight: '600' },
iconContainer: {
justifyContent: 'center',
alignItems: 'center',

View File

@ -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';
@ -30,10 +25,8 @@ import { useSettings } from '../../hooks/context/useSettings';
import useMenuElements from '../../hooks/useMenuElements';
import SafeAreaSectionList from '../../components/SafeAreaSectionList';
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 +107,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 +123,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),
},
});
@ -484,9 +471,7 @@ const WalletsList: React.FC = () => {
const sectionListKeyExtractor = useCallback((item: any, index: any) => {
if (typeof item === 'string') return item;
const txKey = item?.hash || item?.txid;
if (txKey && item?.walletID) return `${txKey}_${item.walletID}`;
return txKey || `${item}${index}`;
return item?.hash || item?.txid || `${item}${index}`;
}, []);
const refreshProps = isDesktop || isElectrumDisabled ? {} : { refreshing: isLoading, onRefresh };
@ -505,9 +490,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 +506,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 +515,7 @@ const WalletsList: React.FC = () => {
// First section: Carousel
if (index === 0) {
return {
length: carouselHeight,
length: CAROUSEL_HEIGHT,
offset: 0,
index,
};
@ -538,13 +528,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,13 +547,11 @@ const WalletsList: React.FC = () => {
initialNumToRender={10}
renderSectionFooter={renderSectionFooter}
sections={sections}
floatingButtonHeight={floatingButtonHeight}
floatingButtonHeight={70}
maxToRenderPerBatch={10}
updateCellsBatchingPeriod={50}
getItemLayout={getItemLayout}
ignoreTopInset={true} // Ignore top inset as the screen header already handles it
// On iOS 26+, let the section headers scroll naturally with the content rather than sticking
stickySectionHeadersEnabled={!isIOS26OrHigher}
{...refreshProps}
/>
{renderScanButton()}

View File

@ -4,7 +4,6 @@ import { element, waitFor } from 'detox';
import {
confirmPasswordDialog,
dismissAlertByText,
expectToBeVisible,
extractTextFromElementById,
goBack,
@ -194,36 +193,31 @@ describe('BlueWallet UI Tests - no wallets', () => {
await waitFor(element(by.id('NotificationsSwitch')))
.toBeVisible()
.withTimeout(10000);
await element(by.id('NotificationsSwitch')).tap();
// Toggle notifications on/off. On iOS 26 simulators notifications are always
// denied, triggering a native UIAlertController whose buttons liquid glass
// can make un-tappable by Detox. If the alert cannot be dismissed, relaunch
// the app to recover instead of failing the entire settings test.
let notifDialogStuck = false;
// If notifications are not enabled on the device, an alert will appear
try {
await element(by.id('NotificationsSwitch')).tap();
const dismissed1 = await dismissAlertByText('OK', 10000);
if (dismissed1) {
await sleep(500);
await element(by.id('NotificationsSwitch')).tap();
await dismissAlertByText('OK', 10000);
} else {
notifDialogStuck = true;
}
} catch (e) {
console.warn('Notifications toggle skipped due to alert interaction issue:', e.message);
notifDialogStuck = true;
await waitFor(element(by.text('OK')))
.toBeVisible()
.withTimeout(3000);
await element(by.text('OK')).tap();
} catch (_) {
// Alert not shown, which is fine - notifications might be enabled
}
await element(by.id('NotificationsSwitch')).tap();
// If notifications are not enabled on the device, an alert will appear
try {
await waitFor(element(by.text('OK')))
.toBeVisible()
.withTimeout(3000);
await element(by.text('OK')).tap();
} catch (_) {
// Alert not shown, which is fine - notifications might be enabled
}
if (notifDialogStuck) {
// Dialog blocks all interaction; relaunch the app to clear it
await device.launchApp({ newInstance: true });
await waitForId('WalletsList');
await element(by.id('SettingsButton')).tap();
} else {
await goBack();
await goBack();
}
await goBack();
await goBack();
} else {
await goBack();
}

View File

@ -58,20 +58,8 @@ export async function waitForText(text, timeout = 33000) {
await waitFor(element(by.text(text)))
.toBeVisible()
.withTimeout(timeout / 2);
return true;
} catch (err) {
// iOS 26 liquid glass: text rendered inside/over the glass header (e.g. the wallet name on
// the transactions hero) can fail Detox's 75%-pixel toBeVisible check while still being
// present and on-screen — same root cause as the goBack() back-button workaround. Fall back
// to existence in the hierarchy so a glass false-negative does not fail an otherwise valid run.
try {
await waitFor(element(by.text(text)))
.toExist()
.withTimeout(3000);
return true;
} catch (_) {
rethrowWithCallsite(err, callsite);
}
rethrowWithCallsite(err, callsite);
}
}
@ -228,44 +216,15 @@ export async function helperCreateWallet(walletName) {
await element(by.id('ActivateBitcoinButton')).tap();
await element(by.id('ActivateBitcoinButton')).tap();
// why tf we need 2 taps for it to work..? mystery
await tapAndTapAgainIfElementIsNotVisible('Create', 'PleaseBackupScrollView');
// iOS 26 liquid glass: the navigation transition after tapping "Create" triggers
// glass animations that never fully settle, keeping the app in a "busy" state.
// Detox synchronization waits for idle before proceeding, causing an infinite hang.
// Disable sync for the remainder of wallet creation and re-enable once we're back
// on the home screen where the glass animations have settled.
const isIOS = device.getPlatform() === 'ios';
if (isIOS) {
await device.disableSynchronization();
}
try {
await element(by.id('Create')).tap();
await sleep(500);
try {
await waitFor(element(by.id('PleaseBackupScrollView')))
.toBeVisible()
.withTimeout(15000);
} catch (_) {
await element(by.id('Create')).tap();
await sleep(500);
await waitFor(element(by.id('PleaseBackupScrollView')))
.toBeVisible()
.withTimeout(15000);
}
await waitFor(element(by.id('PleasebackupOk')))
.toBeVisible()
.whileElement(by.id('PleaseBackupScrollView'))
.scroll(500, 'down'); // in case emu screen is small and it doesnt fit
await waitFor(element(by.id('PleasebackupOk')))
.toBeVisible()
.whileElement(by.id('PleaseBackupScrollView'))
.scroll(500, 'down'); // in case emu screen is small and it doesnt fit
await element(by.id('PleasebackupOk')).tap();
await sleep(1000);
await scrollUpOnHomeScreen();
} finally {
if (isIOS) {
await device.enableSynchronization();
}
}
await element(by.id('PleasebackupOk')).tap();
await scrollUpOnHomeScreen();
await expect(element(by.id('WalletsList'))).toBeVisible();
await element(by.id('WalletsList')).swipe('right', 'fast', 1); // in case emu screen is small and it doesnt fit
await sleep(200);
@ -338,46 +297,6 @@ export async function tapIfTextPresent(text) {
// no need to check for visibility, just silently ignore exception if such testID is not present
}
/**
* Dismisses a native UIAlertController by tapping a button with the given text.
* On iOS 26 liquid glass, `waitFor().toBeVisible()` never resolves for alert
* buttons because the glass material fails Detox's pixel visibility check.
* This helper disables Detox synchronization (which can also hang on glass
* animations) and polls with direct tap attempts and label fallbacks.
*
* @returns true if the alert was dismissed, false if no alert was found
*/
export async function dismissAlertByText(text, timeoutMs = 10000) {
const isIOS = device.getPlatform() === 'ios';
if (isIOS) {
await device.disableSynchronization();
}
const deadline = Date.now() + timeoutMs;
let dismissed = false;
try {
while (Date.now() < deadline) {
// by.text — works on preiOS 26 and some iOS 26 alerts
try {
await element(by.text(text)).atIndex(0).tap();
dismissed = true;
break;
} catch (_) {}
// by.label — accessibility label, works when text matching differs
try {
await element(by.label(text)).atIndex(0).tap();
dismissed = true;
break;
} catch (_) {}
await sleep(500);
}
} finally {
if (isIOS) {
await device.enableSynchronization();
}
}
return dismissed;
}
/**
* Confirms password dialogs in a platform-safe way.
* Android must tap a visible confirmation to keep test flow deterministic.
@ -454,14 +373,8 @@ export async function goBack() {
// and when a modal covers a stack that also has a back button, the covered
// one can precede the visible one in match order (seen with Reduce Motion on).
// Probe attributes and only tap an element detox reports as visible & hittable.
//
// iOS 26 liquid glass: the native back button reports visible=false because
// the glass material fails Detox's 75%-pixel visibility check, yet the button
// IS functionally hittable. We first try (visible && hittable), then fall back
// to (hittable only) for the glass case.
let lastErr;
for (let attempt = 0; attempt < 10; attempt++) {
// Pass 1: prefer visible + hittable elements
for (const matcher of candidates) {
for (let idx = 0; idx < 6; idx++) {
let attrs;
@ -480,25 +393,6 @@ export async function goBack() {
}
}
}
// Pass 2: accept hittable-only elements (iOS 26 liquid glass back button)
for (const matcher of candidates) {
for (let idx = 0; idx < 6; idx++) {
let attrs;
try {
attrs = await element(matcher).atIndex(idx).getAttributes();
} catch (err) {
lastErr = err;
break;
}
if (attrs.hittable === false) continue;
try {
await element(matcher).atIndex(idx).tap();
return;
} catch (err) {
lastErr = err;
}
}
}
await sleep(500);
}

View File

@ -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');
});
});

View File

@ -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);
});
});

View File

@ -1,10 +0,0 @@
{
"xfp": "B68AF6E4",
"account": 0,
"p2wsh_deriv": "m/48h/0h/0h/2h",
"p2wsh": "Zpub74w9dfoeurKrKXE3SPRpFquLPTkiCuSwGuhDzBgbE42w5ShB2FxMjmJyjZpSJ6WhLt8y1PeFHQELGgq2GmktviFDH8yFWYRWg4xQiw3v335",
"p2sh_deriv": "m/45h",
"p2sh": "xpub69EKPNo9Jkd6v2h7xNKw5RdbFBoaHEcstXcRNfcQ2jg71iFpobCwcxfJjaV2ycGy218f2jM1znqs1SDkqMiR7fbyBVJwzacg2QarGt1gtJg",
"p2sh_p2wsh_deriv": "m/48h/0h/0h/1h",
"p2sh_p2wsh": "Ypub6k6tL18jmAnNRGZpk4u3WPGDmWMkdZNmx3MySYdQywCwMMHqNoKHeqLAgU6pFokHKQFdi88vAW4g3TEsCAymoq5LnFXd54RkQ8m3AD9f81J"
}

View File

@ -1160,7 +1160,6 @@ describe('LightningArkWallet — addInvoice + payInvoice (mocked SDK runtime)',
// Real BOLT11 with amount = 0.0001 BTC (10000 sat) so it passes the limits assertion.
const invoice =
'lnbc100u1p50528cpp5rhy4fgs0ff23asecxtxt9zvc3apn0p8h7fxsj0d5k7j3x92zwhlqdq5w3jhxapqd9h8vmmfvdjscqrp80xqyf8ucsp5vcsrzye432n9wh0zwuv5z8y5n9zvkwpctr685e80utzc2yueccms9qxpqysgqd87swq3hput9k6llp0wxg098hc7ge3e5nrtnvak6zreywzaf4k9s8d3u4hrmt3m22kf0jt7ruqj0caknk5ykzdenjdphz50t7xrstnqqn6aw0m';
const expectedPaymentHash = w.decodeInvoice(invoice).payment_hash;
fakeArkadeSwaps.sendLightningPayment.mockResolvedValue({ amount: 10_000, preimage: 'pre', txid: 'tx' });
await w.payInvoice(invoice);
@ -1168,11 +1167,6 @@ describe('LightningArkWallet — addInvoice + payInvoice (mocked SDK runtime)',
assert.strictEqual(fakeArkadeSwaps.sendLightningPayment.mock.calls.length, 1);
assert.strictEqual(fakeArkadeSwaps.sendLightningPayment.mock.calls[0][0].invoice, invoice);
assert.strictEqual(fakeWallet.sendBitcoin.mock.calls.length, 0, 'Ark sendBitcoin must not run for BOLT11');
assert.deepStrictEqual(w.last_paid_invoice_result, {
payment_preimage: 'pre',
payment_hash: expectedPaymentHash,
payment_request: invoice,
});
});
it('payInvoice routes a valid Ark address through Wallet.sendBitcoin', async () => {

View File

@ -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 () {

View File

@ -2161,31 +2161,6 @@ describe('multisig-cosigner', () => {
assert.strictEqual(c3.getPath(), "m/48'/0'/0'/2'");
});
it('can parse unchained json', () => {
const unchainedJson = require('./fixtures/unchained.json');
const cosigner = new MultisigCosigner(JSON.stringify(unchainedJson));
assert.ok(cosigner.isValid());
assert.strictEqual(cosigner.howManyCosignersWeHave(), 3);
assert.strictEqual(cosigner.getFp(), '');
assert.strictEqual(cosigner.getXpub(), '');
assert.strictEqual(cosigner.getPath(), '');
const [c1, c2, c3] = cosigner.getAllCosigners();
assert.strictEqual(c1.getXpub(), unchainedJson.p2sh);
assert.strictEqual(c1.getFp(), 'B68AF6E4');
assert.strictEqual(c1.getPath(), "m/45'");
assert.strictEqual(c2.getXpub(), unchainedJson.p2sh_p2wsh);
assert.strictEqual(c2.getFp(), 'B68AF6E4');
assert.strictEqual(c2.getPath(), "m/48'/0'/0'/1'");
assert.strictEqual(c3.getXpub(), unchainedJson.p2wsh);
assert.strictEqual(c3.getFp(), 'B68AF6E4');
assert.strictEqual(c3.getPath(), "m/48'/0'/0'/2'");
});
it('can parse plain Zpub', () => {
const cosigner = new MultisigCosigner(Zpub1);
assert.ok(cosigner.isValid());