Compare commits

..

12 Commits

Author SHA1 Message Date
GLaDOS
32d3f77f4f
Merge pull request #8694 from BlueWallet/renovate/rubygems-concurrent-ruby-vulnerability
Some checks failed
BuildReleaseApk / buildReleaseApk (push) Has been cancelled
Build Release and Upload to TestFlight (iOS) / build (push) Has been cancelled
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Has been cancelled
BuildReleaseApk / browserstack (push) Has been cancelled
OPS: Update dependency concurrent-ruby to '< 1.3.8' [SECURITY]
2026-06-22 17:13:35 +01:00
GLaDOS
f26ff9189c
Merge pull request #8695 from BlueWallet/fix-walletcarouselclipping
FIX: clipping wallet balance on carousel
2026-06-22 17:13:29 +01:00
ncoelho
1fa290652c FIX: clipping wallet balance on carousel 2026-06-22 15:41:14 +02:00
renovate[bot]
099f6f46a6
OPS: Update dependency concurrent-ruby to '< 1.3.8' [SECURITY] 2026-06-22 13:32:53 +00:00
Nuno
01a11bc8dd
FIX: text size on main app views (#8689)
* fix: text size on wallet view

* fix big font sizes

* fix lint

* fix Glados comments

* fix: run prettier

---------

Co-authored-by: Ivan Vershigora <ivan.vershigora@gmail.com>
2026-06-22 15:27:48 +02:00
GLaDOS
6639891c24
Merge pull request #8632 from BlueWallet/fix-custom-input-lag
Some checks failed
Build Release and Upload to TestFlight (iOS) / build (push) Has been cancelled
BuildReleaseApk / buildReleaseApk (push) Has been cancelled
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Has been cancelled
BuildReleaseApk / browserstack (push) Has been cancelled
FIX: Amount input lag in rbf custom fee input
2026-06-20 11:45:47 +01:00
GLaDOS
4029d294f8
Merge pull request #8566 from BlueWallet/cryptojs
Some checks are pending
Build Release and Upload to TestFlight (iOS) / build (push) Waiting to run
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Blocked by required conditions
BuildReleaseApk / buildReleaseApk (push) Waiting to run
BuildReleaseApk / browserstack (push) Blocked by required conditions
REF: swap crypto-js for @noble/ciphers + hashes
2026-06-19 15:00:49 +01:00
Ivan Vershigora
276a9ea8f8
REF: swap crypto-js for @noble/ciphers + hashes 2026-06-19 12:23:35 +01:00
Nuno
d415f1a0b8
feat: iOS 26 glass (#8508)
Some checks failed
Build Release and Upload to TestFlight (iOS) / build (push) Has been cancelled
BuildReleaseApk / buildReleaseApk (push) Has been cancelled
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Has been cancelled
BuildReleaseApk / browserstack (push) Has been cancelled
2026-06-18 16:37:24 +01:00
Nuno
6124cf1c04
fix: key on tx list (#8687) 2026-06-18 14:17:47 +01:00
Ojok Emmanuel Nsubuga
81cf0011b3
Merge branch 'master' into fix-custom-input-lag 2026-06-14 14:27:05 +03:00
Ojok Emmanuel Nsubuga
d259e68a85 FIX: Amount input lag in rbf custom fee input 2026-06-08 08:44:57 +03:00
85 changed files with 1799 additions and 722 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.4'
gem 'concurrent-ruby', '< 1.3.8'
# 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.3)
concurrent-ruby (1.3.7)
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.4)
concurrent-ruby (< 1.3.8)
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.3) sha256=4f9cd28965c4dcf83ffd3ea7304f9323277be8525819cb18a3b61edcb56a7c6a
concurrent-ruby (1.3.7) sha256=4412caec3a5ea2e5fdc52076724c071a81f2c0593d83b2ac8cbb8ca63b3151b0
connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a
csv (3.3.5) sha256=6e5134ac3383ef728b7f02725d9872934f523cb40b961479f69cf3afa6c8e73f
declarative (0.0.20) sha256=8021dd6cb17ab2b61233c56903d3f5a259c5cf43c80ff332d447d395b17d9ff9

View File

@ -1,23 +1,98 @@
import AES from 'crypto-js/aes';
import Utf8 from 'crypto-js/enc-utf8';
import { cbc } from '@noble/ciphers/aes';
import { md5 } from '@noble/hashes/legacy';
import { randomBytes } from '@noble/hashes/utils';
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 ciphertext = AES.encrypt(data, password);
return ciphertext.toString();
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]));
}
/**
* 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 {
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;
// 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;
}
}

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 CryptoJS from 'crypto-js';
import { cbc } from '@noble/ciphers/aes';
import ecc from '../blue_modules/noble_ecc';
import { parse } from 'url'; // eslint-disable-line n/no-deprecated-api
import { fetch } from '../util/fetch';
@ -321,13 +321,24 @@ export default class Lnurl {
}
static decipherAES(ciphertextBase64: string, preimageHex: string, ivBase64: string): string {
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);
// 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 '';
}
}
getCommentAllowed(): number | false {

View File

@ -52,7 +52,14 @@ const useFloatButtonAnimation = (initialHeight: number) => {
};
};
const useFloatButtonLayout = (width: number, sizeClass: SizeClass) => {
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 lastVerticalDecision = useRef(false);
const shouldUseVerticalLayout = useCallback(
@ -152,15 +159,19 @@ const useFloatButtonLayout = (width: number, sizeClass: SizeClass) => {
[width, sizeClass, shouldUseVerticalLayout],
);
const calculateContainerHeight = useCallback((childrenCount: number, isVerticalLayout: boolean) => {
if (!isVerticalLayout) return { height: '8%', minHeight: LAYOUT.BUTTON_HEIGHT };
const calculateContainerHeight = useCallback(
(childrenCount: number, isVerticalLayout: boolean) => {
const buttonHeight = getScaledButtonHeight(fontScale);
if (!isVerticalLayout) return { height: '8%', minHeight: buttonHeight };
const totalButtonsHeight = childrenCount * LAYOUT.BUTTON_HEIGHT;
const totalMarginsHeight = (childrenCount - 1) * LAYOUT.BUTTON_MARGIN;
const calculatedHeight = totalButtonsHeight + totalMarginsHeight;
const totalButtonsHeight = childrenCount * buttonHeight;
const totalMarginsHeight = (childrenCount - 1) * LAYOUT.BUTTON_MARGIN;
const calculatedHeight = totalButtonsHeight + totalMarginsHeight;
return { height: calculatedHeight };
}, []);
return { height: calculatedHeight };
},
[fontScale],
);
const calculateButtonFontSize = useMemo(() => {
const divisor = sizeClass === SizeClass.Large ? 22 : sizeClass === SizeClass.Regular ? 24 : 28;
@ -267,6 +278,7 @@ interface FButtonProps {
isVertical?: boolean;
borderRadius?: number;
fontSize?: number;
buttonHeight?: number;
disabled?: boolean;
testID?: string;
onPress: () => void;
@ -277,13 +289,14 @@ 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 }: ButtonContentProps) => {
const ButtonContent = ({ icon, text, textStyle, buttonHeight }: ButtonContentProps) => {
const computedStyle = StyleSheet.flatten(textStyle);
const fontSize = computedStyle.fontSize || LAYOUT.MAX_BUTTON_FONT_SIZE;
const iconSize = getScaledIconSize(Number(fontSize));
@ -307,9 +320,14 @@ const ButtonContent = ({ icon, text, textStyle }: ButtonContentProps) => {
}
return (
<View style={buttonContentStaticStyles.contentContainer}>
<View style={[buttonContentStaticStyles.contentContainer, { minHeight: buttonHeight }]}>
<View style={buttonStyles.iconContainer}>{scaledIcon}</View>
<Text numberOfLines={1} adjustsFontSizeToFit style={[textStyle, buttonStyles.centeredText, { lineHeight: fontSize * 1.2 }]}>
<Text
numberOfLines={1}
adjustsFontSizeToFit
minimumFontScale={0.8}
style={[textStyle, buttonStyles.centeredText, { lineHeight: fontSize * 1.2 }]}
>
{text}
</Text>
</View>
@ -325,6 +343,7 @@ export const FButton = ({
isVertical,
borderRadius = LAYOUT.PILL_BORDER_RADIUS,
fontSize = LAYOUT.MAX_BUTTON_FONT_SIZE,
buttonHeight = LAYOUT.BUTTON_HEIGHT,
testID,
...props
}: FButtonProps) => {
@ -347,6 +366,8 @@ export const FButton = ({
return {
root: {
...baseStyles,
height: buttonHeight,
minHeight: buttonHeight,
backgroundColor: colors.buttonBackgroundColor,
},
text: {
@ -360,7 +381,7 @@ export const FButton = ({
marginBottom: buttonContentStaticStyles.marginBottom,
textBase: buttonContentStaticStyles.textBase,
};
}, [colors, fontSize]);
}, [colors, fontSize, buttonHeight]);
const style: Record<string, any> = {};
const additionalStyles = !last ? (isVertical ? customButtonStyles.marginBottom : customButtonStyles.marginRight) : {};
@ -397,7 +418,7 @@ export const FButton = ({
style={[buttonStyles.root, customButtonStyles.root, style, { borderRadius }]}
{...props}
>
<ButtonContent icon={icon} text={text} textStyle={textStyle} />
<ButtonContent icon={icon} text={text} textStyle={textStyle} buttonHeight={buttonHeight} />
</TouchableOpacity>
</Animated.View>
);
@ -405,8 +426,9 @@ export const FButton = ({
export const FContainer = forwardRef<View, FContainerProps>((props, ref) => {
const insets = useSafeAreaInsets();
const { height, width } = useWindowDimensions();
const { height, width, fontScale } = useWindowDimensions();
const { sizeClass } = useSizeClass();
const scaledButtonHeight = getScaledButtonHeight(fontScale);
const childrenCount = React.Children.toArray(props.children).filter(Boolean).length;
@ -419,6 +441,7 @@ 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,
@ -508,7 +531,7 @@ export const FContainer = forwardRef<View, FContainerProps>((props, ref) => {
useEffect(() => {
debouncedCalculateLayout();
}, [debouncedCalculateLayout, width, height, childrenCount, sizeClass]);
}, [debouncedCalculateLayout, width, height, childrenCount, sizeClass, fontScale]);
const onLayout = (event: { nativeEvent: { layout: { width: number } } }) => {
const { width: currentLayoutWidth } = event.nativeEvent.layout;
@ -545,6 +568,7 @@ export const FContainer = forwardRef<View, FContainerProps>((props, ref) => {
isVertical,
borderRadius: buttonBorderRadius,
fontSize: buttonFontSize,
buttonHeight: scaledButtonHeight,
});
};
@ -561,10 +585,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 : null,
isVertical ? containerHeight : { minHeight: scaledButtonHeight },
{ transform: [{ translateY: slideAnimation }] },
],
[props.inline, bottomInsets, effectiveNewWidth, isVertical, containerHeight, slideAnimation],
[props.inline, bottomInsets, effectiveNewWidth, isVertical, containerHeight, slideAnimation, scaledButtonHeight],
);
return (

View File

@ -1,10 +1,13 @@
import React, { useMemo } from 'react';
import { Pressable, StyleProp, StyleSheet, Switch, SwitchProps, Text, TextStyle, View, ViewStyle } from 'react-native';
import { Pressable, StyleProp, StyleSheet, Switch, SwitchProps, Text, TextStyle, useWindowDimensions, 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>;
@ -55,12 +58,20 @@ 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: {
@ -72,7 +83,7 @@ const ListItem: React.FC<ListItemProps> = React.memo(
color: colors.alternativeTextColor,
fontWeight: '400',
paddingVertical: switchProps ? 8 : 0,
lineHeight: 20,
lineHeight: Math.round(20 * fontScale),
fontSize: 14,
marginTop: 2,
},
@ -93,7 +104,7 @@ const ListItem: React.FC<ListItemProps> = React.memo(
const enableFeedback = !noFeedback && !!onPress && !disabled;
const renderContent = () => (
<View style={styles.contentRow}>
<View style={[styles.contentRow, contentRowStyle]}>
{leftAvatar && (
<View style={styles.leftAvatarContainer}>
{leftAvatar}
@ -114,7 +125,14 @@ const ListItem: React.FC<ListItemProps> = React.memo(
{rightTitle || rightSubtitle ? (
<View style={styles.rightColumn}>
{rightTitle ? (
<Text style={rightTitleStyle} numberOfLines={1} accessibilityRole="text" selectable={rightTitleSelectable}>
<Text
style={rightTitleStyle}
numberOfLines={1}
adjustsFontSizeToFit
minimumFontScale={0.75}
accessibilityRole="text"
selectable={rightTitleSelectable}
>
{rightTitle}
</Text>
) : null}
@ -192,16 +210,20 @@ const styles = StyleSheet.create({
},
content: {
flex: 1,
flexShrink: 1,
minWidth: 0,
justifyContent: 'center',
},
leftAvatarContainer: {
flexDirection: 'row',
alignItems: 'center',
alignSelf: 'center',
},
rightColumn: {
marginStart: 8,
minWidth: 0,
flexShrink: 0,
alignItems: 'flex-end',
alignSelf: 'center',
},
rightMemoWrapper: {
flexShrink: 1,

View File

@ -1,7 +1,7 @@
import React, { useCallback, useState, useEffect, useRef } from 'react';
import { StyleSheet, ViewStyle, ActivityIndicator, Platform, Animated, View, Text, Pressable } from 'react-native';
import { useLocale } from '@react-navigation/native';
import ReanimatedSwipeable, { SwipeableMethods } from 'react-native-gesture-handler/ReanimatedSwipeable';
import { Swipeable } from 'react-native-gesture-handler';
import { ExtendedTransaction, LightningTransaction, Transaction, TWallet } from '../class/wallets/types';
import loc from '../loc';
import { TransactionListItem } from './TransactionListItem';
@ -16,7 +16,6 @@ import { MultisigHDWallet } from '../class/wallets/multisig-hd-wallet';
import { AbstractHDElectrumWallet } from '../class/wallets/abstract-hd-electrum-wallet';
import { WatchOnlyWallet } from '../class/wallets/watch-only-wallet';
import WalletListItem from './WalletListItem';
import Icon from './Icon';
const getHdElectrumWallet = (wallet: TWallet): AbstractHDElectrumWallet | undefined => {
const w: unknown = wallet;
@ -61,8 +60,6 @@ interface ManageWalletsListItemProps {
item: Item;
isDraggingDisabled: boolean;
handleToggleHideBalance: (wallet: TWallet) => void;
handleCycleBalanceUnit: (wallet: TWallet) => void;
preferredFiatLabel?: string;
state: { wallets: TWallet[]; searchQuery: string; isSearchFocused?: boolean };
navigateToWallet: (wallet: TWallet) => void;
navigateToAddress: (address: string, walletID: string) => void;
@ -88,8 +85,6 @@ const ManageWalletsListItem: React.FC<ManageWalletsListItemProps> = ({
onPressIn,
onPressOut,
handleToggleHideBalance,
handleCycleBalanceUnit,
preferredFiatLabel,
isActive,
globalDragActive,
style,
@ -99,7 +94,7 @@ const ManageWalletsListItem: React.FC<ManageWalletsListItemProps> = ({
const [isLoading, setIsLoading] = useState(false);
const prevIsActive = useRef(isActive);
const swipeableRef = useRef<SwipeableMethods | null>(null);
const swipeableRef = useRef<Swipeable | null>(null);
const swipeInProgressRef = useRef(false);
useEffect(() => {
@ -142,54 +137,12 @@ const ManageWalletsListItem: React.FC<ManageWalletsListItemProps> = ({
const canSwipe = !isActive && !globalDragActive;
const isHidden = !!wallet.hideBalance;
const currentUnit = wallet.getPreferredBalanceUnit();
const fiatLabel = preferredFiatLabel ?? 'USD';
let nextUnitLabel: string;
if (currentUnit === BitcoinUnit.BTC) {
nextUnitLabel = loc.total_balance_view.display_in_sats;
} else if (currentUnit === BitcoinUnit.SATS) {
nextUnitLabel = loc.formatString(loc.total_balance_view.display_in_fiat, { currency: fiatLabel });
} else {
nextUnitLabel = loc.total_balance_view.display_in_bitcoin;
}
const onToggle = () => {
handleToggleHideBalance(wallet);
swipeableRef.current?.close?.();
};
const onCycleUnit = () => {
handleCycleBalanceUnit(wallet);
swipeableRef.current?.close?.();
};
const renderLeftActions = () => (
<View style={styles.leftActionsContainer}>
<Pressable
style={({ pressed }) => [
styles.leftAction,
{ backgroundColor: colors.buttonBackgroundColor },
pressed && styles.leftActionPressed,
]}
onPress={onToggle}
accessibilityRole="button"
accessibilityLabel={isHidden ? loc.transactions.details_balance_show : loc.transactions.details_balance_hide}
testID={isHidden ? 'SwipeShowBalance' : 'SwipeHideBalance'}
>
<Icon
name={isHidden ? 'eye' : 'eye-slash'}
type="font-awesome"
size={20}
color={colors.buttonTextColor}
containerStyle={styles.leftActionIcon}
/>
<Text style={[styles.leftActionText, { color: colors.buttonTextColor }]}>
{isHidden ? loc.transactions.details_balance_show : loc.transactions.details_balance_hide}
</Text>
</Pressable>
</View>
);
const renderRightActions = () => (
<View style={styles.rightActionsContainer}>
<Pressable
@ -198,19 +151,13 @@ const ManageWalletsListItem: React.FC<ManageWalletsListItemProps> = ({
{ backgroundColor: colors.buttonBackgroundColor },
pressed && styles.rightActionPressed,
]}
onPress={onCycleUnit}
onPress={onToggle}
accessibilityRole="button"
accessibilityLabel={nextUnitLabel}
testID="SwipeCycleBalanceUnit"
testID={isHidden ? 'SwipeShowBalance' : 'SwipeHideBalance'}
>
<Icon
name="arrow-right-arrow-left"
type="font-awesome-6"
size={18}
color={colors.buttonTextColor}
containerStyle={styles.rightActionIcon}
/>
<Text style={[styles.rightActionText, { color: colors.buttonTextColor }]}>{nextUnitLabel}</Text>
<Text style={[styles.rightActionText, { color: colors.buttonTextColor }]}>
{isHidden ? loc.wallets.swipe_balance_show : loc.wallets.swipe_balance_hide}
</Text>
</Pressable>
</View>
);
@ -235,7 +182,7 @@ const ManageWalletsListItem: React.FC<ManageWalletsListItemProps> = ({
if (!canSwipe) return content;
return (
<ReanimatedSwipeable
<Swipeable
ref={r => {
swipeableRef.current = r;
}}
@ -248,16 +195,13 @@ const ManageWalletsListItem: React.FC<ManageWalletsListItemProps> = ({
onSwipeableClose={() => {
swipeInProgressRef.current = false;
}}
renderLeftActions={renderLeftActions}
renderRightActions={renderRightActions}
friction={2}
leftThreshold={40}
rightThreshold={40}
overshootLeft={false}
overshootRight={false}
>
{content}
</ReanimatedSwipeable>
</Swipeable>
);
} else if (item.type === ItemType.TransactionSection && item.data) {
try {
@ -472,33 +416,11 @@ const styles = StyleSheet.create({
height: 1,
width: '100%',
},
leftActionsContainer: {
justifyContent: 'center',
alignItems: 'flex-start',
},
leftAction: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 18,
height: '100%',
},
leftActionPressed: {
opacity: 0.85,
},
leftActionIcon: {
marginRight: 8,
},
leftActionText: {
fontSize: 15,
fontWeight: '600',
},
rightActionsContainer: {
justifyContent: 'center',
alignItems: 'flex-end',
},
rightAction: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 18,
@ -507,9 +429,6 @@ const styles = StyleSheet.create({
rightActionPressed: {
opacity: 0.85,
},
rightActionIcon: {
marginRight: 8,
},
rightActionText: {
fontSize: 15,
fontWeight: '600',

View File

@ -67,26 +67,25 @@ const ReplaceFeeSuggestions: React.FC<ReplaceFeeSuggestionsProps> = ({ onFeeSele
}, []);
const handleFeeSelection = (feeType: NetworkTransactionFeeType) => {
if (feeType !== NetworkTransactionFeeType.CUSTOM) {
Keyboard.dismiss();
if (feeType === NetworkTransactionFeeType.CUSTOM) {
setSelectedFeeType(feeType);
return;
}
Keyboard.dismiss();
if (networkFees) {
let selectedFee: number;
switch (feeType) {
case NetworkTransactionFeeType.FAST:
selectedFee = networkFees.fastestFee;
onFeeSelected(networkFees.fastestFee);
break;
case NetworkTransactionFeeType.MEDIUM:
selectedFee = networkFees.mediumFee;
onFeeSelected(networkFees.mediumFee);
break;
case NetworkTransactionFeeType.SLOW:
selectedFee = networkFees.slowFee;
break;
case NetworkTransactionFeeType.CUSTOM:
selectedFee = Number(customFeeValue);
onFeeSelected(networkFees.slowFee);
break;
}
onFeeSelected(selectedFee);
setSelectedFeeType(feeType);
}
};
@ -94,7 +93,8 @@ const ReplaceFeeSuggestions: React.FC<ReplaceFeeSuggestionsProps> = ({ onFeeSele
const handleCustomFeeChange = (customFee: string) => {
const sanitizedFee = customFee.replace(/[^0-9]/g, '');
setCustomFeeValue(sanitizedFee);
handleFeeSelection(NetworkTransactionFeeType.CUSTOM);
onFeeSelected(Number(sanitizedFee));
setSelectedFeeType(NetworkTransactionFeeType.CUSTOM);
};
return (
@ -156,7 +156,10 @@ const ReplaceFeeSuggestions: React.FC<ReplaceFeeSuggestionsProps> = ({ onFeeSele
ref={customTextInput}
maxLength={9}
style={[styles.customFeeInput, stylesHook.customFeeInput]}
onFocus={() => handleCustomFeeChange(customFeeValue)}
onFocus={() => {
setSelectedFeeType(NetworkTransactionFeeType.CUSTOM);
onFeeSelected(Number(customFeeValue));
}}
placeholder={loc.send.fee_satvbyte}
placeholderTextColor="#81868e"
inputAccessoryViewID={DismissKeyboardInputAccessoryViewID}

View File

@ -7,10 +7,18 @@ 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, ...otherProps } = props;
const {
style,
contentContainerStyle,
floatingButtonHeight = 0,
headerHeight = 0,
disableDefaultTopPadding = false,
...otherProps
} = props;
const { colors } = useTheme();
const insets = useSafeAreaInsets();
@ -32,7 +40,10 @@ const SafeAreaScrollView = forwardRef<ScrollView, SafeAreaScrollViewProps>((prop
if (headerHeight > 0) {
return headerHeight;
}
// iOS safe area or no status bar
if (disableDefaultTopPadding) {
return 0;
}
// Preserve legacy behavior for existing screens
return insets.top > 0 ? 5 : 0;
})(),
};
@ -48,7 +59,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]);
}, [insets, contentContainerStyle, floatingButtonHeight, headerHeight, disableDefaultTopPadding]);
return (
<ScrollView

View File

@ -1,5 +1,5 @@
import React, { useMemo, useCallback } from 'react';
import { TouchableOpacity, Text, StyleSheet, View } from 'react-native';
import { TouchableOpacity, Text, StyleSheet, View, useWindowDimensions } from 'react-native';
import { useStorage } from '../hooks/context/useStorage';
import loc, { formatBalanceWithoutSuffix } from '../loc';
import { BitcoinUnit } from '../models/bitcoinUnits';
@ -22,6 +22,7 @@ const TotalWalletsBalance: React.FC = React.memo(() => {
setTotalBalancePreferredUnitStorage,
} = useSettings();
const { colors } = useTheme();
const { fontScale } = useWindowDimensions();
const totalBalanceFormatted = useMemo(() => {
const totalBalance = wallets.reduce((prev, curr) => {
@ -31,6 +32,22 @@ 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(
() => [
{
@ -92,13 +109,20 @@ const TotalWalletsBalance: React.FC = React.memo(() => {
return (
<ToolTipMenu actions={toolTipActions} onPressMenuItem={onPressMenuItem} shouldOpenOnLongPress style={styles.menuContainer}>
<View style={styles.container}>
<Text style={styles.label}>{loc.wallets.total_balance}</Text>
<TouchableOpacity onPress={handleBalanceOnPress}>
<Text style={[styles.balance, { color: colors.foregroundColor }]}>
{totalBalanceFormatted}{' '}
<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}
{totalBalancePreferredUnit !== BitcoinUnit.LOCAL_CURRENCY && (
<Text style={[styles.currency, { color: colors.foregroundColor }]}>{totalBalancePreferredUnit}</Text>
<Text style={[styles.currency, { color: colors.foregroundColor }]}>{` ${totalBalancePreferredUnit}`}</Text>
)}
</Text>
</TouchableOpacity>
@ -116,6 +140,11 @@ const styles = StyleSheet.create({
alignItems: 'flex-start',
paddingHorizontal: 16,
paddingVertical: 8,
width: '100%',
},
balanceTouchable: {
alignSelf: 'stretch',
width: '100%',
},
label: {
fontSize: 14,
@ -125,6 +154,7 @@ 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 } from 'react-native';
import { Animated, Easing, Linking, Pressable, Text, TextStyle, ViewStyle, StyleSheet, View, useWindowDimensions } from 'react-native';
import Lnurl from '../class/lnurl';
import { LightningArkWallet } from '../class/wallets/lightning-ark-wallet';
import { LightningTransaction, Transaction } from '../class/wallets/types';
@ -29,9 +29,6 @@ import { uint8ArrayToHex } from '../blue_modules/uint8array-extras';
import ListItem from './ListItem';
const styles = StyleSheet.create({
dateLine: {
fontSize: 13,
},
fullWidthButton: {
width: '100%',
alignSelf: 'stretch',
@ -133,6 +130,7 @@ 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,
@ -248,6 +246,7 @@ 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,
@ -262,6 +261,7 @@ 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={<Text style={styles.dateLine}>{dateLine}</Text>}
subtitle={dateLine}
chevron={false}
rightTitle={rowTitle}
rightTitleStyle={rowTitleStyle}

View File

@ -1,8 +1,8 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import Clipboard from '@react-native-clipboard/clipboard';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import Animated, { useAnimatedStyle, useSharedValue, withSpring, withTiming } from 'react-native-reanimated';
import { Platform, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
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,35 +14,39 @@ 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?: (isShouldBeVisible: boolean) => void;
onWalletBalanceVisibilityChange?: (shouldHideBalance: 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) {
@ -73,13 +77,14 @@ const TransactionsNavigationHeader: React.FC<TransactionsNavigationHeaderProps>
const handleBalanceVisibility = useCallback(() => {
onWalletBalanceVisibilityChange?.(!hideBalance);
}, [onWalletBalanceVisibilityChange, hideBalance]);
}, [hideBalance, onWalletBalanceVisibilityChange]);
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) {
@ -88,7 +93,6 @@ const TransactionsNavigationHeader: React.FC<TransactionsNavigationHeaderProps>
newWalletPreferredUnit = BitcoinUnit.BTC;
}
console.debug('[UnitSwitch/UI] next unit resolved', { walletID: wallet.getID?.(), next: newWalletPreferredUnit });
onWalletUnitChange(newWalletPreferredUnit);
};
@ -103,9 +107,9 @@ const TransactionsNavigationHeader: React.FC<TransactionsNavigationHeaderProps>
const onPressMenuItem = useCallback(
(id: string) => {
if (id === 'walletBalanceVisibility') {
if (id === actionKeys.WalletBalanceVisibility) {
handleBalanceVisibility();
} else if (id === 'copyToClipboard') {
} else if (id === actionKeys.CopyToClipboard) {
handleCopyPress();
}
},
@ -140,148 +144,160 @@ 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: 'walletBalanceVisibility',
id: actionKeys.WalletBalanceVisibility,
text: loc.transactions.details_balance_show,
icon: {
iconValue: 'eye',
},
icon: actionIcons.Eye,
},
]
: [
{
id: 'walletBalanceVisibility',
id: actionKeys.WalletBalanceVisibility,
text: loc.transactions.details_balance_hide,
icon: {
iconValue: 'eye.slash',
},
icon: actionIcons.EyeSlash,
},
{
id: 'copyToClipboard',
id: actionKeys.CopyToClipboard,
text: loc.transactions.details_copy,
icon: {
iconValue: 'doc.on.doc',
},
icon: actionIcons.Clipboard,
},
];
}, [hideBalance]);
useEffect(() => {
console.debug('[UnitSwitch/UI] render state', {
walletID: wallet.getID?.(),
unit,
hideBalance,
preferredFiat: preferredFiatCurrency?.endPointKey,
switching: unitSwitching,
});
}, [wallet, unit, hideBalance, preferredFiatCurrency, unitSwitching]);
return (
<LinearGradient colors={WalletGradient.gradientsFor(wallet.type)} style={styles.lineaderGradient}>
<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} />
<View style={styles.contentContainer}>
<Text testID="WalletLabel" numberOfLines={1} style={[styles.walletLabel, { writingDirection: direction }]}>
{wallet.getLabel()}
</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
<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
testID="WalletBalance"
numberOfLines={1}
minimumFontScale={0.5}
adjustsFontSizeToFit
style={[styles.walletBalanceText, animatedBalanceTextStyle]}
style={styles.walletBalanceText}
>
{balance}
</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>
)}
</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>
{wallet.type === MultisigHDWallet.type && (
<TouchableOpacity style={styles.manageFundsButton} accessibilityRole="button" onPress={() => handleManageFundsPressed()}>
<Text style={styles.manageFundsButtonText}>{loc.multisig.manage_keys}</Text>
</TouchableOpacity>
)}
</View>
</LinearGradient>
<View style={styles.bottomBarSpacer}>
<View
style={[
styles.bottomBar,
{
backgroundColor: colors.background,
...Platform.select({
ios: { shadowColor: colors.shadowColor },
android: {},
}),
},
]}
/>
</View>
</View>
);
};
const styles = StyleSheet.create({
lineaderGradient: {
minHeight: 140,
justifyContent: 'flex-start',
position: 'relative',
},
contentContainer: {
padding: 15,
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,
},
}),
},
walletLabel: {
backgroundColor: 'transparent',
fontSize: 19,
color: '#fff',
marginBottom: 10,
color: 'rgba(255, 255, 255, 0.7)',
marginBottom: 4,
},
walletBalance: {
flexShrink: 1,
marginRight: 6,
minHeight: 39,
justifyContent: 'center',
},
balanceSection: {
flexDirection: 'column',
alignItems: 'flex-start',
},
manageFundsButton: {
marginTop: 14,
@ -302,13 +318,13 @@ const styles = StyleSheet.create({
walletBalanceAndUnitContainer: {
flexDirection: 'row',
alignItems: 'center',
paddingRight: 10, // Ensure there's some padding to the right
paddingRight: 10,
},
walletBalanceText: {
color: '#fff',
fontWeight: 'bold',
fontSize: 36,
flexShrink: 1, // Allow the text to shrink if there's not enough space
flexShrink: 1,
},
walletPreferredUnitView: {
justifyContent: 'center',

View File

@ -30,6 +30,7 @@ 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';
@ -37,6 +38,30 @@ 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 {
@ -160,23 +185,28 @@ 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: {
height: 40,
minHeight: 40,
justifyContent: 'center',
},
balanceContainerCompact: {
height: 32,
minHeight: 32,
justifyContent: 'center',
},
image: {
width: 99,
@ -189,9 +219,6 @@ const iStyles = StyleSheet.create({
width: 78,
height: 74,
},
br: {
backgroundColor: 'transparent',
},
label: {
backgroundColor: 'transparent',
fontSize: 19,
@ -206,7 +233,6 @@ const iStyles = StyleSheet.create({
},
balanceCompact: {
fontSize: 28,
lineHeight: 34,
},
latestTx: {
backgroundColor: 'transparent',
@ -282,11 +308,32 @@ export const WalletCarouselItem: React.FC<WalletCarouselItemProps> = React.memo(
const balanceOpacity = useSharedValue(1);
const balanceTranslateY = useSharedValue(0);
const { colors } = useTheme();
const { width } = useWindowDimensions();
const { width, fontScale } = 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;
@ -431,23 +478,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]}>
<LinearGradient
colors={WalletGradient.gradientsFor(item.type)}
style={[iStyles.grad, isCompact && iStyles.gradCompact, scaledCardStyles.grad]}
>
<ImageBackground source={image} style={[iStyles.image, isCompact && iStyles.imageCompact]} />
<View style={[iStyles.gradContent, isCompact && iStyles.gradContentCompact]}>
<Text style={iStyles.br} />
<View style={[iStyles.gradContent, isCompact && iStyles.gradContentCompact, !isCompact && scaledCardStyles.gradContent]}>
{!isPlaceHolder && (
<>
<Text
numberOfLines={1}
style={[
iStyles.label,
isCompact && iStyles.labelCompact,
{ color: colors.inverseForegroundColor, writingDirection: direction },
]}
style={[iStyles.label, isCompact && iStyles.labelCompact, scaledCardStyles.label, cardTextStyle]}
>
{renderHighlightedText ? renderHighlightedText(walletLabel, searchQuery || '') : walletLabel}
</Text>
<View style={[iStyles.balanceContainer, isCompact && iStyles.balanceContainerCompact]}>
<View
style={[iStyles.balanceContainer, isCompact && iStyles.balanceContainerCompact, scaledCardStyles.balanceContainer]}
>
{hideBalance ? (
<>
<BlueSpacing10 />
@ -457,11 +504,13 @@ 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,
{ color: colors.inverseForegroundColor, writingDirection: direction },
isCompact ? scaledCardStyles.balanceCompact : scaledCardStyles.balance,
cardTextStyle,
animatedBalanceStyle,
]}
>
@ -469,24 +518,20 @@ export const WalletCarouselItem: React.FC<WalletCarouselItemProps> = React.memo(
</Animated.Text>
)}
</View>
<Text style={iStyles.br} />
<View style={scaledCardStyles.textSpacer} />
<Text
numberOfLines={1}
style={[
iStyles.latestTx,
isCompact && iStyles.latestTxCompact,
{ color: colors.inverseForegroundColor, writingDirection: direction },
]}
adjustsFontSizeToFit
minimumFontScale={0.8}
style={[iStyles.latestTx, isCompact && iStyles.latestTxCompact, scaledCardStyles.latestTx, cardTextStyle]}
>
{loc.wallets.list_latest_transaction}
</Text>
<Text
numberOfLines={1}
style={[
iStyles.latestTxTime,
isCompact && iStyles.latestTxTimeCompact,
{ color: colors.inverseForegroundColor, writingDirection: direction },
]}
adjustsFontSizeToFit
minimumFontScale={0.8}
style={[iStyles.latestTxTime, isCompact && iStyles.latestTxTimeCompact, scaledCardStyles.latestTxTime, cardTextStyle]}
>
{latestTransactionText}
</Text>
@ -541,7 +586,7 @@ const WalletsCarousel = forwardRef<CarouselListRefType, WalletsCarouselProps>((p
animateChanges = false,
} = props;
const { width } = useWindowDimensions();
const { width, fontScale } = useWindowDimensions();
const itemWidth = React.useMemo(() => getWalletCarouselItemWidth(width), [width]);
const snapInterval = React.useMemo(() => itemWidth, [itemWidth]);
const snapOffsets = React.useMemo(() => {
@ -650,7 +695,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 : 195; // 195 is the approximate height of wallet card
const itemSize = horizontal ? itemWidth : WALLET_CAROUSEL_HEIGHT;
flatListRef.current.scrollToOffset({
offset: itemSize * walletIndex,
animated,
@ -772,7 +817,7 @@ const WalletsCarousel = forwardRef<CarouselListRefType, WalletsCarouselProps>((p
const keyExtractor = useCallback((item: TWallet, index: number) => (item?.getID ? item.getID() : index.toString()), []);
const sliderHeight = 195;
const sliderHeight = getWalletCarouselHeight(fontScale);
useEffect(() => {
return () => {
@ -855,7 +900,8 @@ const WalletsCarousel = forwardRef<CarouselListRefType, WalletsCarouselProps>((p
const cStyles = StyleSheet.create({
content: {
paddingTop: 16,
paddingTop: scaleLayoutUp(WALLET_CAROUSEL_PADDING_TOP, fontScale),
paddingBottom: scaleLayoutUp(WALLET_CAROUSEL_PADDING_BOTTOM, fontScale),
},
contentLargeScreen: {
paddingHorizontal: sizeClass === SizeClass.Large ? 16 : 12,
@ -886,7 +932,7 @@ const WalletsCarousel = forwardRef<CarouselListRefType, WalletsCarouselProps>((p
automaticallyAdjustContentInsets
automaticallyAdjustKeyboardInsets
automaticallyAdjustsScrollIndicatorInsets
style={{ minHeight: sliderHeight + 12 }}
style={{ minHeight: sliderHeight }}
onScrollToIndexFailed={onScrollToIndexFailed}
ListFooterComponent={onNewWalletPress ? <NewWalletPanel onPress={onNewWalletPress} /> : null}
{...props}

View File

@ -31,6 +31,8 @@ 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,
@ -107,6 +109,15 @@ 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,
@ -192,6 +203,7 @@ 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,6 +14,8 @@ 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',
@ -101,6 +103,7 @@ 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,8 +245,6 @@
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>UIDesignRequiresCompatibility</key>
<true/>
<key>UTExportedTypeDeclarations</key>
<array>
<dict>

View File

@ -366,6 +366,7 @@
"rbf_title": "تسريع العملية (RBF)",
"status_bump": "تسريع العملية",
"status_cancel": "إلغاء العملية",
"transactions_count": "عدد العمليات",
"txid": "معرّف العملية",
"updating": "جارٍ التحديث ...",
"watchOnlyWarningDescription": "تنبيه احتيال: انتبه إلى أن المحتالين عادةً ما يستخدمون هذا النوع من المحفظة \"للمشاهدة فقط\" لمحاولة السرقة من المستخدمين. هذه المحفظة التي لا يمكنك التحكم بها أو الإرسال منها، إلا بتصريح جهاز آخر، المحفظة تسمح فقط بمراقبة الرصيد.",
@ -490,6 +491,8 @@
"clear_clipboard_on_import": "مسح الحافظة عند الاستيراد",
"details_del_wb_err": "مبلغ الرصيد المقدم لا يطابق رصيد هذه المحفظة. يُرجى المحاولة مرة أخرى.",
"details_display": "العرض في الشاشة الرئيسية",
"swipe_balance_hide": "إخفاء",
"swipe_balance_show": "إظهار",
"drag_to_reorder": "اسحب لإعادة الترتيب",
"clear_search": "مسح البحث",
"learn_more": "معرفة المزيد",

View File

@ -373,6 +373,7 @@
"rbf_title": "Паскорыць (RBF)",
"status_bump": "Паскорыць",
"status_cancel": "Адмяніць",
"transactions_count": "Колькасьць трансакцыяў",
"txid": "ID трансакцыі",
"updating": "Абнаўленьне...",
"watchOnlyWarningTitle": "Папярэджаньне аб бясьпецы",
@ -446,6 +447,8 @@
"details_show_addresses": "Паказаць адрасы",
"details_title": "Кашалёк",
"wallets": "Кашалькі",
"swipe_balance_hide": "Схаваць",
"swipe_balance_show": "Паказаць",
"drag_to_reorder": "Перацягніце для пераўпарадкаваньня",
"clear_search": "Ачысьціць пошук",
"details_type": "Тып",

View File

@ -370,6 +370,7 @@
"rbf_title": "Ускори (RBF)",
"status_bump": "Ускори",
"status_cancel": "Отмени",
"transactions_count": "Брой транзакции",
"txid": "ID на транзакцията",
"updating": "Обновяване...",
"watchOnlyWarningTitle": "Предупреждение за сигурност",
@ -439,6 +440,8 @@
"details_show_addresses": "Покажи адресите",
"details_title": "Портфейл",
"wallets": "Портфейли",
"swipe_balance_hide": "Скрий",
"swipe_balance_show": "Покажи",
"drag_to_reorder": "Влачете, за да пренаредите",
"clear_search": "Изчисти търсенето",
"details_type": "Тип",

View File

@ -379,6 +379,7 @@
"eta_1d": "زمووݩ تخمینی: حدود 1 رۊز دی",
"list_title_sent": "فیشناڌه وابیڌه",
"rbf_explain": "ای تراکونش ن وا تراکونش دیر ک کارمزدس بیشتره، جانشین اکۊنیم تا زۊڌتر استخراج بۊ. ای کار ن RBF—جانشینی وا کارمزد اگۊن.",
"transactions_count": "شومار تراکونشا",
"txid": "شناسه تراکونش",
"updating": "ورۊ رسۊوی...",
"watchOnlyWarningTitle": "هشڌار امنیتی",
@ -449,6 +450,7 @@
"more_info": "دووسمندیا قلوه",
"details_delete_anyway": "و هر هال پاک بۊ",
"add_lightning": "لایتنینگ",
"swipe_balance_hide": "بؽڌار",
"details_delete": "پاک کردن",
"add_bitcoin_explain": "کیف پیل بیت کوین ساڌه ۉ پۊر هؽز",
"add_entropy_reset_title": "وورنشۊوی آنتروپی",
@ -476,6 +478,7 @@
"details_connected_to": "منپیز و",
"details_del_wb_err": "مقدار مۉجۊڌی داڌه وابیڌه وا مۉجۊڌی ای کیف پیل هومخۊوݩ نؽ. تی کۊن ز نۊ تفره کۊنی.",
"details_del_wb_q": "ای کیف پیل مۉجۊڌی داره. پؽش ز ادامه، ویرت بۊ ک بؽ عبارت بازیابی ای کیف پیل، نتری دارایی ن بازیابی کۊنی. سی پؽش گری ز پاک کردن نا خۊسته، تی کۊن مۉجۊڌی کیف پیلت {balance} ساتۊشی ن بزن.",
"swipe_balance_show": "نشۉݩ داڌن",
"drag_to_reorder": "سی ترتیو دی، بکش",
"clear_search": "پاک کردن پیتینیڌن",
"import_explanation": "تی کۊن وشه ها سید، کلید عمومی، WIF، یا هر چی ک داری ن بزن. BlueWallet پوی تلاش خوسه اکونه تا فورمت زبال ن خومه بزنه ۉ کیف پیل ته و من بئره.",

View File

@ -376,6 +376,7 @@
"rbf_title": "Accelerar (RBF)",
"status_bump": "Accelerar",
"status_cancel": "Cancel·lar la Transacció",
"transactions_count": "Recompte de transaccions",
"txid": "ID de la transacció",
"updating": "Actualitzant...",
"watchOnlyWarningTitle": "Advertència de seguretat",
@ -445,6 +446,8 @@
"details_show_addresses": "Mostrar adreces",
"details_title": "Detalls del moneder",
"wallets": "moneders",
"swipe_balance_hide": "Amagar",
"swipe_balance_show": "Mostrar",
"drag_to_reorder": "Arrossegueu per reordenar",
"clear_search": "Esborrar cerca",
"details_type": "Tipus",

View File

@ -376,6 +376,7 @@
"rbf_title": "Poplatek za popostrčení (RBF)",
"status_bump": "Poplatek za popostrčení",
"status_cancel": "Zrušit transakci",
"transactions_count": "Počet transakcí",
"txid": "ID transakce",
"updating": "Aktualizování…",
"watchOnlyWarningTitle": "Bezpečnostní upozornění",
@ -445,6 +446,8 @@
"details_show_addresses": "Zobrazit adresy",
"details_title": "Peněženka",
"wallets": "Peněženky",
"swipe_balance_hide": "Skrýt",
"swipe_balance_show": "Zobrazit",
"drag_to_reorder": "Přetažením změníte pořadí",
"clear_search": "Vymazat vyhledávání",
"details_type": "Typ",

View File

@ -376,6 +376,7 @@
"rbf_title": "Cyflymu (RBF)",
"status_bump": "Cyflymu",
"status_cancel": "Canslo",
"transactions_count": "Nifer y Trafodion",
"txid": "ID y Trafodyn",
"updating": "Diweddaru...",
"watchOnlyWarningTitle": "Rhybudd diogelwch",
@ -445,6 +446,8 @@
"details_show_addresses": "Dangos cyfeiriadau",
"details_title": "Waled",
"wallets": "Waledi",
"swipe_balance_hide": "Cuddio",
"swipe_balance_show": "Dangos",
"drag_to_reorder": "Llusgo i aildrefnu",
"clear_search": "Clirio chwiliad",
"details_type": "Math",

View File

@ -376,6 +376,7 @@
"rbf_title": "Fremskynd (RBF)",
"status_bump": "Fremskynd",
"status_cancel": "Annuller",
"transactions_count": "Antal transaktioner",
"txid": "Transaktions-ID",
"updating": "Opdaterer...",
"watchOnlyWarningTitle": "Sikkerhedsadvarsel",
@ -460,6 +461,8 @@
"details_master_fingerprint": "Master fingerprint",
"details_multisig_type": "multisig",
"details_show_addresses": "Vis adresser",
"swipe_balance_hide": "Skjul",
"swipe_balance_show": "Vis",
"drag_to_reorder": "Træk for at omarrangere",
"clear_search": "Ryd søgning",
"details_use_with_hardware_wallet": "Brug med hardware wallet",

View File

@ -376,6 +376,7 @@
"rbf_title": "TRX-Gebühr erhöhen (RBF)",
"status_bump": "TRX-Gebühr erhöhen",
"status_cancel": "Transaktion abbrechen",
"transactions_count": "Anzahl Transaktionen",
"txid": "Transaktions-ID",
"updating": "Aktualisiere....",
"watchOnlyWarningTitle": "Sicherheitswarnung",
@ -445,6 +446,8 @@
"details_show_addresses": "Adressen anzeigen",
"details_title": "Wallet",
"wallets": "Wallets",
"swipe_balance_hide": "Verbergen",
"swipe_balance_show": "Anzeigen",
"drag_to_reorder": "Ziehen zum Neuanordnen",
"clear_search": "Suche löschen",
"details_type": "Typ",

View File

@ -377,6 +377,7 @@
"rbf_explain": "Θα αντικαταστήσουμε αυτή τη συναλλαγή με μία με υψηλότερη προμήθεια ώστε να εξορυχθεί γρηγορότερα. Αυτό λέγεται RBF—Replace by Fee.",
"rbf_title": "Επιτάχυνση (RBF)",
"status_bump": "Επιτάχυνση",
"transactions_count": "Πλήθος συναλλαγών",
"updating": "Ενημέρωση...",
"watchOnlyWarningTitle": "Προειδοποίηση ασφαλείας",
"watchOnlyWarningDescription": "Να είστε προσεκτικοί με τους απατεώνες που συχνά χρησιμοποιούν πορτοφόλια “μόνο για παρακολούθηση” για να εξαπατήσουν χρήστες. Αυτά τα πορτοφόλια δεν σας επιτρέπουν να ελέγχετε ή να στέλνετε κεφάλαια· σας επιτρέπουν μόνο να βλέπετε το υπόλοιπο.",
@ -474,6 +475,8 @@
"details_export_history": "Εξαγωγή ιστορικού σε CSV",
"details_master_fingerprint": "Αποτύπωμα κύριου κλειδιού",
"details_multisig_type": "multisig",
"swipe_balance_hide": "Απόκρυψη",
"swipe_balance_show": "Εμφάνιση",
"drag_to_reorder": "Σύρετε για αναδιάταξη",
"clear_search": "Εκκαθάριση αναζήτησης",
"enter_bip38_password": "Εισάγετε κωδικό για αποκρυπτογράφηση",

View File

@ -454,6 +454,8 @@
"restore_swap_activity": "Restore swap activity",
"restore_swap_activity_done": "Swap activity restored.",
"wallets": "Wallets",
"swipe_balance_hide": "Hide",
"swipe_balance_show": "Show",
"drag_to_reorder": "Drag to reorder",
"clear_search": "Clear search",
"details_type": "Type",

View File

@ -366,6 +366,7 @@
"rbf_title": "Incrementar comisión (RBF)",
"status_bump": "Aumentar comisión",
"status_cancel": "Cancelar transacción",
"transactions_count": "Número de transacciones",
"txid": "ID de transacción",
"updating": "Actualizando...",
"transaction_loading_error": "Ha habido un problema al cargar la transacción. Por favor, inténtalo de nuevo más tarde.",
@ -489,6 +490,8 @@
"clear_clipboard_on_import": "Borrar portapapeles al importar",
"details_del_wb_err": "La cantidad de balance proporcionada no coincide con el balance de esta cartera. Por favor, inténtalo de nuevo.",
"details_display": "Mostrar en la pantalla de inicio",
"swipe_balance_hide": "Ocultar",
"swipe_balance_show": "Mostrar",
"drag_to_reorder": "Arrastra para reordenar",
"clear_search": "Borrar búsqueda",
"import_success_watchonly": "Tu cartera ha sido importada correctamente. ADVERTENCIA: esta es una cartera de solo lectura, NO puedes gastar desde ella.",

View File

@ -377,6 +377,7 @@
"rbf_title": "Aumentar Comisión (RBF)",
"status_bump": "Aumentar Comisión",
"status_cancel": "Cancelar Transacción",
"transactions_count": "Número de Transacciones",
"txid": "ID de Transacción",
"updating": "Actualizando...",
"watchOnlyWarningTitle": "Advertencia de seguridad",
@ -445,6 +446,8 @@
"details_show_addresses": "Mostrar direcciones",
"details_title": "Billetera",
"wallets": "Billeteras",
"swipe_balance_hide": "Ocultar",
"swipe_balance_show": "Mostrar",
"drag_to_reorder": "Arrastra para reordenar",
"clear_search": "Limpiar búsqueda",
"details_type": "Tipo",

View File

@ -377,6 +377,7 @@
"rbf_title": "Kiirenda (RBF)",
"status_bump": "Kiirenda",
"status_cancel": "Tühista",
"transactions_count": "Tehingute arv",
"txid": "Tehingu ID",
"updating": "Uuendamine...",
"watchOnlyWarningTitle": "Turvahoiatus",
@ -445,6 +446,8 @@
"details_show_addresses": "Näita aadresse",
"details_title": "Rahakott",
"wallets": "Rahakotid",
"swipe_balance_hide": "Peida",
"swipe_balance_show": "Näita",
"drag_to_reorder": "Lohista ümberjärjestamiseks",
"clear_search": "Tühjenda otsing",
"details_type": "Tüüp",

View File

@ -376,6 +376,7 @@
"rbf_title": "تسریع (RBF)",
"status_bump": "تسریع",
"status_cancel": "لغو تراکنش",
"transactions_count": "تعداد تراکنش‌ها",
"txid": "شناسهٔ تراکنش",
"updating": "درحال به‌روزرسانی…",
"watchOnlyWarningTitle": "هشدار امنیتی",
@ -445,6 +446,8 @@
"details_show_addresses": "نمایش آدرس‌ها",
"details_title": "کیف پول",
"wallets": "کیف پول‌ها",
"swipe_balance_hide": "پنهان‌کردن",
"swipe_balance_show": "نمایش",
"drag_to_reorder": "برای ترتیب‌بندی بکشید",
"clear_search": "پاک‌کردن جستجو",
"details_type": "نوع",

View File

@ -373,6 +373,7 @@
"rbf_title": "Nosta siirtomaksua (RBF)",
"status_bump": "Nosta siirtomaksua",
"status_cancel": "Peruuta Siirtotapahtuma",
"transactions_count": "Siirtotapahtumien määrä",
"txid": "Siirtotapahtuman tunnus",
"updating": "Päivitetään...",
"watchOnlyWarningTitle": "Turvallisuusvaroitus",
@ -512,6 +513,8 @@
"details_delete_anyway": "Poista silti",
"add_lndhub_error": "Annettu solmun osoite on virheellinen LNDhub-solmu.",
"add_wallet_seed_length": "Palautuslauseen pituus",
"swipe_balance_hide": "Piilota",
"swipe_balance_show": "Näytä",
"drag_to_reorder": "Vedä järjestääksesi uudelleen",
"clear_search": "Tyhjennä haku",
"details_delete_wallet_error_message": "Tämän lompakon ilmoituksista poistamisen vahvistamisessa ilmeni ongelma — tämä voi johtua verkko-ongelmasta tai heikosta yhteydestä. Jos jatkat, saatat silti saada ilmoituksia tähän lompakkoon liittyvistä siirtotapahtumista myös sen poistamisen jälkeen."

View File

@ -376,6 +376,7 @@
"rbf_title": "Hækka avgjald (RBF)",
"status_bump": "Hækka avgjald",
"status_cancel": "Avlýs flyting",
"transactions_count": "Flytingar",
"txid": "Flytingareyðmerki",
"updating": "Innlesur…",
"watchOnlyWarningTitle": "Ávaring",
@ -429,6 +430,8 @@
"clear_clipboard_on_import": "Reinsa setiborð eftir innsetan",
"clear_search": "Reinsa leiting",
"drag_to_reorder": "Drag fyri at umraða",
"swipe_balance_hide": "Fjal",
"swipe_balance_show": "Vís",
"details_address": "Adressa",
"details_advanced": "Víðkaðar stillingar",
"details_are_you_sure": "Ert tú vís/ur?",

View File

@ -371,6 +371,7 @@
"rbf_title": "Augmenter les frais (RBF)",
"status_bump": "Augmenter les frais",
"status_cancel": "Annuler la transaction",
"transactions_count": "Nombre de transactions",
"txid": "ID de transaction",
"updating": "Chargement...",
"watchOnlyWarningTitle": "Avertissement de sécurité",
@ -512,6 +513,8 @@
"more_info": "Plus d'information",
"details_delete_wallet_error_message": "Un problème est survenu lors de la confirmation de la suppression de ce portefeuille des notifications. Cela pourrait être dû à un problème de réseau ou à une mauvaise connexion. Si vous continuez, vous pourriez continuer à recevoir des notifications pour les transactions liées à ce portefeuille, même après sa suppression.",
"details_delete_anyway": "Supprimer quand même",
"swipe_balance_hide": "Cacher",
"swipe_balance_show": "Montrer",
"drag_to_reorder": "Glisser pour réorganiser",
"clear_search": "Effacer la recherche",
"details_type": "Type"

View File

@ -373,6 +373,7 @@
"rbf_title": "העלאת עמלה (RBF)",
"status_bump": "העלאת עמלה",
"status_cancel": "ביטול פעולה",
"transactions_count": "מספר תנועות",
"txid": "מזהה פעולה",
"updating": "מעדכן...",
"watchOnlyWarningTitle": "אזהרת אבטחה",
@ -511,6 +512,8 @@
"manage_wallets_search_placeholder": "חיפוש ארנקים, כתובות, פעולות ותזכירים",
"more_info": "מידע נוסף",
"details_delete_anyway": "מחק בכל אופן",
"swipe_balance_hide": "הסתרה",
"swipe_balance_show": "הצג",
"drag_to_reorder": "גרור לסידור מחדש",
"clear_search": "נקה חיפוש",
"import_discovery_offline": "BlueWallet נמצא כעת במצב לא מקוון. במצב זה, לא ניתן לאמת את קיומו של הארנק, ולכן תצטרך לבחור את הארנק הנכון באופן ידני",

View File

@ -376,6 +376,7 @@
"rbf_title": "Ubrzaj (RBF)",
"status_bump": "Ubrzaj",
"status_cancel": "Otkaži",
"transactions_count": "Broj transakcija",
"txid": "ID transakcije",
"updating": "Ažuriranje...",
"watchOnlyWarningTitle": "Sigurnosno upozorenje",
@ -445,6 +446,8 @@
"details_show_addresses": "Prikaži adrese",
"details_title": "Novčanik",
"wallets": "Novčanici",
"swipe_balance_hide": "Sakrij",
"swipe_balance_show": "Prikaži",
"drag_to_reorder": "Povucite za promjenu redoslijeda",
"clear_search": "Očisti pretragu",
"details_type": "Tip",

View File

@ -366,6 +366,7 @@
"rbf_title": "Kiváltási díj (RBF)",
"status_bump": "Kiváltási díj",
"status_cancel": "Tranzakció törlése",
"transactions_count": "Tranzakciók száma",
"txid": "Tranzakció azonosító",
"updating": "Frissítés...",
"transaction_loading_error": "Hiba történt a tranzakció betöltésekor. Kérlek, próbáld újra később.",
@ -496,6 +497,8 @@
"clear_clipboard_on_import": "Vágólap törlése importáláskor",
"details_display": "Megjelenítés a kezdőképernyőn",
"details_export_history": "Előzmények exportálása CSV-be",
"swipe_balance_hide": "Elrejtés",
"swipe_balance_show": "Mutatás",
"drag_to_reorder": "Húzd az átrendezéshez",
"clear_search": "Keresés törlése",
"import_success_watchonly": "A tárcád sikeresen importálva. FIGYELEM: Ez egy csak megtekintésre szolgáló tárca, NEM tudsz róla költeni.",

View File

@ -376,6 +376,7 @@
"rbf_title": "Percepat (RBF)",
"status_bump": "Percepat",
"status_cancel": "Batalkan Transaksi",
"transactions_count": "Jumlah Transaksi",
"txid": "ID Transaksi",
"updating": "Memperbaharui...",
"watchOnlyWarningTitle": "Peringatan keamanan",
@ -445,6 +446,8 @@
"details_show_addresses": "Tunjukkan alamat",
"details_title": "Dompet",
"wallets": "Dompet",
"swipe_balance_hide": "Sembunyikan",
"swipe_balance_show": "Tampilkan",
"drag_to_reorder": "Seret untuk menyusun ulang",
"clear_search": "Bersihkan pencarian",
"details_type": "Tipe",

View File

@ -368,6 +368,7 @@
"rbf_title": "Aumenta la commissione (RBF)",
"status_bump": "Aumenta la commissione",
"status_cancel": "Annulla transazione",
"transactions_count": "Conteggio transazioni",
"txid": "ID della transazione",
"updating": "Aggiornamento...",
"transaction_loading_error": "Si è verificato un problema nel caricamento della transazione. Per favore riprova più tardi.",
@ -489,6 +490,8 @@
"clear_clipboard_on_import": "Cancella appunti dopo l'importazione",
"details_del_wb_err": "L'importo del saldo fornito non corrisponde al saldo di questo portafoglio. Per favore riprova.",
"details_display": "Mostra nella schermata Home",
"swipe_balance_hide": "Nascondi",
"swipe_balance_show": "Mostra",
"drag_to_reorder": "Trascina per riordinare",
"clear_search": "Cancella ricerca",
"learn_more": "Scopri di più",

View File

@ -392,6 +392,7 @@
"rbf_title": "手数料をバンプ (RBF)",
"status_bump": "手数料をバンプ",
"status_cancel": "トランザクションをキャンセル",
"transactions_count": "トランザクションカウント",
"txid": "トランザクションID",
"updating": "更新中…",
"watchOnlyWarningTitle": "セキュリティ警告",
@ -513,6 +514,8 @@
"more_info": "詳細情報",
"details_delete_wallet_error_message": "ウォレットが通知から削除されたかの確認に問題が生じました—ネットワークの問題か、接続が弱いためかもしれません。続行すると、ウォレットを削除した後でも、関連するトランザクションの通知を受け取る可能性があります。",
"details_delete_anyway": "とにかく削除",
"swipe_balance_hide": "非表示",
"swipe_balance_show": "表示",
"drag_to_reorder": "ドラッグして並び替え",
"clear_search": "検索をクリア"
},

View File

@ -342,6 +342,7 @@
"list_title_received": "Алынған",
"open_url_error": "Сілтемені әдепкі браузерде ашу мүмкін болмады. Әдепкі браузерді ауыстырып, қайталап көріңіз.",
"rbf_explain": "Бұл транзакцияны тезірек өндірілуі үшін жоғары комиссиямен ауыстырамыз. Бұл RBF — Replace by Fee деп аталады.",
"transactions_count": "Транзакциялар саны",
"txid": "Транзакция идентификаторы",
"updating": "Жаңартылуда...",
"watchOnlyWarningTitle": "Қауіпсіздік ескертуі",
@ -419,6 +420,8 @@
"details_multisig_type": "multisig",
"details_show_xpub": "Әмиянның xpub-ын көрсету",
"details_show_addresses": "Мекенжайларды көрсету",
"swipe_balance_hide": "Жасыру",
"swipe_balance_show": "Көрсету",
"drag_to_reorder": "Ретін өзгерту үшін сүйреңіз",
"clear_search": "Іздеуді тазалау",
"details_use_with_hardware_wallet": "Аппараттық әмиянмен пайдалану",

View File

@ -369,6 +369,7 @@
"status_bump": "ಶುಲ್ಕ ಹೆಚ್ಚಿಸಿ",
"transaction_loading_error": "ವಹಿವಾಟನ್ನು ಲೋಡ್ ಮಾಡಲು ಸಮಸ್ಯೆ ಇದೆ. ಪುನಃ ಪ್ರಯತ್ನಿಸಿ.",
"transaction_not_available": "ವಹಿವಾಟು ಲಭ್ಯವಿಲ್ಲ",
"transactions_count": "ವಹಿವಾಟುಗಳ ಸಂಖ್ಯೆ",
"txid": "ವಹಿವಾಟು ID",
"updating": "ನವೀಕರಿಸಲಾಗುತ್ತಿದೆ...",
"watchOnlyWarningDescription": "ಬಳಕೆದಾರರನ್ನು ವಂಚಿಸಲು “watch-only” ವ್ಯಾಲೆಟ್‌ಗಳನ್ನು ಬಳಸುವ ವಂಚಕರ ಬಗ್ಗೆ ಎಚ್ಚರಿಕೆಯಿಂದಿರಿ. ಈ ವ್ಯಾಲೆಟ್‌ಗಳು ನಿಮಗೆ ಹಣವನ್ನು ನಿಯಂತ್ರಿಸಲು ಅಥವಾ ಕಳುಹಿಸಲು ಅವಕಾಶ ನೀಡುವುದಿಲ್ಲ; ಅವು ಕೇವಲ ಬಾಕಿಯನ್ನು ವೀಕ್ಷಿಸಲು ಅವಕಾಶ ನೀಡುತ್ತವೆ.",
@ -400,6 +401,8 @@
"details_master_fingerprint": "ಮಾಸ್ಟರ್ ಫಿಂಗರ್‌ಪ್ರಿಂಟ್",
"details_title": "ವ್ಯಾಲೆಟ್",
"wallets": "ವ್ಯಾಲೆಟ್‌ಗಳು",
"swipe_balance_hide": "ಮರೆಮಾಡಿ",
"swipe_balance_show": "ತೋರಿಸಿ",
"details_type": "ಪ್ರಕಾರ",
"details_use_with_hardware_wallet": "ಹಾರ್ಡ್‌ವೇರ್ ವ್ಯಾಲೆಟ್‌ನೊಂದಿಗೆ ಬಳಸಿ",
"import_do_import": "ಆಮದು",

View File

@ -376,6 +376,7 @@
"rbf_title": "급행 수수료(RBF)",
"status_bump": "급행 수수료",
"status_cancel": "트랜잭션 취소",
"transactions_count": "거래 건수",
"txid": "트랜잭션 아이디",
"updating": "갱신중...",
"watchOnlyWarningTitle": "보안 경고",
@ -445,6 +446,8 @@
"details_show_addresses": "주소 보이기",
"details_title": "지갑",
"wallets": "지갑",
"swipe_balance_hide": "숨기기",
"swipe_balance_show": "보이기",
"drag_to_reorder": "끌어서 재정렬",
"clear_search": "검색 지우기",
"details_type": "형태",

View File

@ -364,6 +364,7 @@
"rbf_title": "تسریع (RBF)",
"cpfp_title": "افزایش کارمزد (CPFP)",
"cpfp_create": "ساتن",
"transactions_count": "تعداد تراکونشیا",
"eta_10m": "تخمین: تقریبا 10 دیقه",
"eta_3h": "تخمین: تقریبا 3 ساعت",
"eta_1d": "تخمین: تقریبا 1 رۊز",
@ -444,6 +445,8 @@
"total_balance": "گرد مۉجۊدی",
"details_are_you_sure": "ٱطمیون داری؟",
"details_connected_to": "وصل بیه و",
"swipe_balance_hide": "قایم کردن",
"swipe_balance_show": "نشوݩ دؽن",
"clear_search": "پاک کردن جۊرسن",
"manage_title": "دؽونداری کردن کیف پیلٛیا",
"no_results_found": "هؽچی نجۊرست.",

View File

@ -376,6 +376,7 @@
"rbf_title": "Tambah Yuran (RBF)",
"status_bump": "Tambah Yuran",
"status_cancel": "Batalkan Urus Niaga",
"transactions_count": "Bilangan Urus Niaga",
"txid": "KP Urus Niaga",
"updating": "Mengemas kini...",
"watchOnlyWarningTitle": "Amaran keselamatan",
@ -445,6 +446,8 @@
"details_show_addresses": "Paparkan alamat",
"details_title": "Dompet",
"wallets": "Dompet",
"swipe_balance_hide": "Sembunyikan",
"swipe_balance_show": "Tunjukkan",
"drag_to_reorder": "Seret untuk susun semula",
"clear_search": "Kosongkan carian",
"details_type": "Jenis",

View File

@ -376,6 +376,7 @@
"rbf_title": "Betal et høyere gebyr (RBF)",
"status_bump": "Betal et høyere gebyr",
"status_cancel": "Avbryt transaksjon",
"transactions_count": "Antall Transaksjoner",
"txid": "Transaksjons-ID",
"updating": "Oppdaterer...",
"watchOnlyWarningTitle": "Sikkerhetsadvarsel",
@ -444,6 +445,8 @@
"details_show_addresses": "Vis adresser",
"details_title": "Lommebok",
"wallets": "Lommebøker",
"swipe_balance_hide": "Skjul",
"swipe_balance_show": "Vis",
"drag_to_reorder": "Dra for å endre rekkefølge",
"clear_search": "Tøm søk",
"details_use_with_hardware_wallet": "Bruk med maskinvarelommebok",

View File

@ -376,6 +376,7 @@
"rbf_title": "छिटो बनाउनुहोस् (RBF)",
"status_bump": "छिटो बनाउनुहोस्",
"status_cancel": "लेनदेन क्यान्सिल",
"transactions_count": "लेनदेन गणना",
"txid": "लेनदेन ID",
"updating": "अद्यावधिक गर्दै...",
"watchOnlyWarningTitle": "सुरक्षा चेतावनी",
@ -445,6 +446,8 @@
"details_show_addresses": "ठेगानाहरू देखाउनुहोस्",
"details_title": "वालेट",
"wallets": "वालेटहरू",
"swipe_balance_hide": "लुकाउनुहोस्",
"swipe_balance_show": "देखाउनुहोस्",
"drag_to_reorder": "पुनः क्रमबद्ध गर्न तान्नुहोस्",
"clear_search": "खोजी खाली गर्नुहोस्",
"details_type": "प्रकार",

View File

@ -360,6 +360,7 @@
"rbf_title": "Bump fee (RBF)",
"status_bump": "Bump fee",
"status_cancel": "Annuleer transactie",
"transactions_count": "Transactieteller",
"txid": "Transactie ID",
"updating": "Updaten...",
"cancel_explain": "We zullen deze transactie vervangen door een transactie die aan jezelf betaalt en hogere fees heeft. Dit annuleert effectief de huidige transactie. Dit heet RBF — Replace by Fee.",
@ -482,6 +483,8 @@
"details_del_wb_err": "Het opgegeven saldobedrag komt niet overeen met het saldo van deze wallet. Probeer het opnieuw.",
"details_display": "Weergeven op startscherm",
"details_export_history": "Geschiedenis exporteren naar CSV",
"swipe_balance_hide": "Verbergen",
"swipe_balance_show": "Tonen",
"drag_to_reorder": "Sleep om volgorde te wijzigen",
"clear_search": "Zoekopdracht wissen",
"import_success_watchonly": "Je wallet is succesvol geïmporteerd. WAARSCHUWING: Dit is een watch-only-wallet, je kunt er NIET vanaf uitgeven.",

View File

@ -376,6 +376,7 @@
"rbf_title": "Sharp am up (RBF)",
"status_bump": "Sharp am up",
"status_cancel": "Comot transaction",
"transactions_count": "How many transactions",
"txid": "Transaction ID na",
"updating": "Dey update...",
"watchOnlyWarningTitle": "Security wahala",
@ -445,6 +446,8 @@
"details_show_addresses": "Show the addresses",
"details_title": "Wallet wey",
"wallets": "Wallets wey you get",
"swipe_balance_hide": "Hide am",
"swipe_balance_show": "Show am",
"drag_to_reorder": "Drag make you reorder",
"clear_search": "Clear the search",
"details_type": "Wetin kind",

View File

@ -376,6 +376,7 @@
"rbf_title": "Zwiększ opłatę (RBF)",
"status_bump": "Zwiększ opłatę",
"status_cancel": "Anuluj transakcję",
"transactions_count": "Ilość transakcji",
"txid": "ID Transakcji",
"updating": "Aktualizuję...",
"watchOnlyWarningTitle": "Ostrzeżenie bezpieczeństwa",
@ -513,6 +514,8 @@
"more_info": "Więcej informacji",
"details_delete_wallet_error_message": "Nie udało się potwierdzić usunięcia tego portfela z powiadomień możliwe, że przyczyną jest problem z siecią lub słabe połączenie. Jeśli kontynuujesz, możesz nadal otrzymywać powiadomienia o transakcjach związanych z tym portfelem, nawet po jego usunięciu.",
"details_delete_anyway": "Usuń mimo to",
"swipe_balance_hide": "Ukryj",
"swipe_balance_show": "Pokaż",
"drag_to_reorder": "Przeciągnij, aby zmienić kolejność",
"clear_search": "Wyczyść wyszukiwanie"
},

View File

@ -375,6 +375,7 @@
"rbf_title": "Aumentar Taxa (RBF)",
"status_bump": "Aumentar Taxa",
"status_cancel": "Cancelar Transação",
"transactions_count": "Contagem das Transações",
"txid": "ID da transação",
"updating": "Atualizando...",
"watchOnlyWarningTitle": "Alerta de segurança",
@ -512,6 +513,8 @@
"more_info": "Mais informações",
"details_delete_wallet_error_message": "Houve um problema ao confirmar se esta carteira foi removida das notificações — isso pode ser devido a um problema de rede ou conexão ruim. Se você continuar, ainda poderá receber notificações de transações relacionadas a esta carteira, mesmo depois que ela for excluída.",
"details_delete_anyway": "Apagar mesmo assim",
"swipe_balance_hide": "Ocultar",
"swipe_balance_show": "Mostrar",
"drag_to_reorder": "Arraste para reordenar",
"clear_search": "Limpar busca",
"manage_wallets_search_placeholder": "Buscar carteiras, endereços, transações e notas"

View File

@ -376,6 +376,7 @@
"rbf_title": "Aumentar taxa (RBF)",
"status_bump": "Aumento de taxa",
"status_cancel": "Cancelar transação",
"transactions_count": "Número de transações",
"txid": "ID da transação",
"updating": "A atualizar...",
"watchOnlyWarningTitle": "Aviso de segurança",
@ -513,6 +514,8 @@
"more_info": "Saber Mais",
"details_delete_wallet_error_message": "Houve um problema ao confirmar se esta carteira foi removida das notificações — isto pode ser devido a um problema de rede ou ligação fraca. Se continuar, poderá ainda receber notificações de transações relacionadas com esta carteira, mesmo depois desta ter sido eliminada.",
"details_delete_anyway": "Apagar de qualquer forma",
"swipe_balance_hide": "Esconder",
"swipe_balance_show": "Mostrar",
"drag_to_reorder": "Arrastar para reordenar",
"clear_search": "Limpar pesquisa"
},

View File

@ -360,6 +360,7 @@
"rbf_title": "Crește comisionul (RBF)",
"status_bump": "Crește comisionul",
"status_cancel": "Anulează tranzacția",
"transactions_count": "Numărul tranzacțiilor",
"txid": "ID-ul tranzacției",
"updating": "Se actualizează...",
"transaction_loading_error": "A apărut o problemă la încărcarea tranzacției. Te rugăm să încerci din nou mai târziu.",
@ -485,6 +486,8 @@
"details_del_wb_q": "Acest portofel are o balanță. Înainte de a continua, te rugăm să fii conștient că nu vei putea recupera fondurile fără fraza seed a acestui portofel. Pentru a evita ștergerea accidentală, te rugăm să introduci balanța portofelului tău de {balance} satoshi.",
"details_display": "Afișează pe ecranul principal",
"details_export_history": "Exportă istoricul în CSV",
"swipe_balance_hide": "Ascunde",
"swipe_balance_show": "Afișează",
"drag_to_reorder": "Trage pentru a reordona",
"clear_search": "Șterge căutarea",
"import_passphrase": "Frază de acces",

View File

@ -392,6 +392,7 @@
"rbf_title": "Повысить комиссию (RBF)",
"status_bump": "Повысить комиссию",
"status_cancel": "Отменить",
"transactions_count": "Всего транзакций",
"txid": "TXID",
"updating": "Обновление...",
"watchOnlyWarningTitle": "Предупреждение безопасности",
@ -510,6 +511,8 @@
"identity_pubkey": "Identity Pubkey",
"xpub_title": "XPUB кошелька",
"manage_wallets_search_placeholder": "Поиск кошельков, адресов, транзакций и заметок",
"swipe_balance_hide": "Скрыть",
"swipe_balance_show": "Показать",
"drag_to_reorder": "Перетащите для изменения порядка",
"clear_search": "Очистить поиск",
"more_info": "Подробнее",

View File

@ -365,6 +365,7 @@
"rbf_title": "බම්ප් ගාස්තුව (RBF)",
"status_bump": "බම්ප් ගාස්තුව",
"status_cancel": "ගනුදෙනුව අවලංගු කරන්න",
"transactions_count": "ගනුදෙනු ගණන",
"txid": "ගනුදෙනු හැඳුනුම්පත",
"updating": "යාවත්කාලීන කරමින් ...",
"transaction_loading_error": "ගනුදෙනුව පූරණය කිරීමේදී ගැටළුවක් ඇති විය. කරුණාකර පසුව නැවත උත්සාහ කරන්න.",
@ -489,6 +490,8 @@
"details_del_wb_err": "සපයා ඇති ශේෂ මුදල මෙම පසුම්බියේ ශේෂයට නොගැලපේ. කරුණාකර නැවත උත්සාහ කරන්න.",
"details_display": "මුල් තිරයේ පෙන්වන්න",
"details_export_history": "ඉතිහාසය CSV වෙත අපනයනය කරන්න",
"swipe_balance_hide": "සඟවන්න",
"swipe_balance_show": "පෙන්වන්න",
"drag_to_reorder": "නැවත අනුපිළිවෙළ කිරීමට ඇද ගන්න",
"clear_search": "සෙවුම හිස් කරන්න",
"import_success_watchonly": "ඔබේ පසුම්බිය සාර්ථකව ආනයනය කර ඇත. අවවාදයයි: මෙය නැරඹීමට පමණි පසුම්බියකි, ඔබට මෙයින් වියදම් කළ නොහැක.",

View File

@ -376,6 +376,7 @@
"rbf_title": "Navýšiť poplatok za transakciu (RBF)",
"status_bump": "Navýšiť poplatok",
"status_cancel": "Zrušiť transakciu",
"transactions_count": "počet transakcií",
"txid": "ID transakcie",
"updating": "Aktualizuje sa...",
"watchOnlyWarningTitle": "Bezpečnostné upozornenie",
@ -445,6 +446,8 @@
"details_show_addresses": "Zobraziť adresy",
"details_title": "Peňaženka",
"wallets": "peňaženky",
"swipe_balance_hide": "Skryť",
"swipe_balance_show": "Zobraziť",
"drag_to_reorder": "Potiahnutím zmeníte poradie",
"clear_search": "Vymazať vyhľadávanie",
"details_type": "Typ",

View File

@ -367,6 +367,7 @@
"rbf_title": "Povečaj omrežnino (RBF)",
"status_bump": "Povečaj omrežnino",
"status_cancel": "Prekliči transakcijo",
"transactions_count": "Število transakcij",
"txid": "ID transakcije",
"updating": "Osveževanje...",
"transaction_loading_error": "Pri nalaganju transakcije je prišlo do težave. Poskusite znova kasneje.",
@ -489,6 +490,8 @@
"clear_clipboard_on_import": "Počisti odložišče po uvozu",
"details_del_wb_err": "Vneseno stanje se ne ujema s stanjem te denarnice. Poskusite ponovno.",
"details_display": "Prikaži na domačem zaslonu",
"swipe_balance_hide": "Skrij",
"swipe_balance_show": "Prikaži",
"drag_to_reorder": "Povlecite za preureditev",
"clear_search": "Počisti iskanje",
"import_success_watchonly": "Vaša denarnica je bila uspešno uvožena. OPOZORILO: To je opazovalna denarnica, iz nje NE morete zapravljati.",

View File

@ -378,6 +378,7 @@
"rbf_title": "Përshpejto (RBF)",
"status_bump": "Përshpejto",
"status_cancel": "Anulo",
"transactions_count": "Numri i transaksioneve",
"txid": "ID-ja e transaksionit",
"watchOnlyWarningTitle": "Paralajmërim sigurie",
"watchOnlyWarningDescription": "Bëni kujdes nga mashtruesit që shpesh përdorin portofola \"vetëm për shikim\" për të mashtruar përdoruesit. Këto portofola nuk ju lejojnë të kontrolloni ose të dërgoni fonde; ato ju lejojnë vetëm të shihni ballancën.",
@ -467,6 +468,8 @@
"details_master_fingerprint": "Shenjë gishti kryesore",
"details_multisig_type": "multisig",
"details_show_xpub": "Shfaq XPUB-in e portofolit",
"swipe_balance_hide": "Fshih",
"swipe_balance_show": "Shfaq",
"drag_to_reorder": "Tërhiq për të rirenditur",
"clear_search": "Pastro kërkimin",
"details_use_with_hardware_wallet": "Përdor me portofol hardware",

View File

@ -376,6 +376,7 @@
"rbf_title": "Ubrzaj (RBF)",
"status_bump": "Ubrzaj",
"status_cancel": "Otkaži",
"transactions_count": "Broj transakcija",
"txid": "ID transakcije",
"updating": "Ažuriranje...",
"watchOnlyWarningTitle": "Bezbednosno upozorenje",
@ -445,6 +446,8 @@
"details_show_addresses": "Prikaži adrese",
"details_title": "Novčanik",
"wallets": "Novčanici",
"swipe_balance_hide": "Sakrij",
"swipe_balance_show": "Prikaži",
"drag_to_reorder": "Prevucite za preuređivanje",
"clear_search": "Obriši pretragu",
"details_type": "Tip",

View File

@ -366,6 +366,7 @@
"rbf_title": "Höj avgift (RBF)",
"status_bump": "Höj avgift",
"status_cancel": "Avbryt transaktion",
"transactions_count": "Antal transaktioner",
"txid": "Transaktions ID",
"updating": "Uppdaterar...",
"transaction_loading_error": "Det uppstod ett problem när transaktionen skulle läsas in. Försök igen senare.",
@ -491,6 +492,8 @@
"clear_clipboard_on_import": "Töm urklipp vid import",
"details_del_wb_err": "Det angivna saldot matchar inte denna plånboks saldo. Försök igen.",
"details_display": "Visa på startskärmen",
"swipe_balance_hide": "Dölj",
"swipe_balance_show": "Visa",
"drag_to_reorder": "Dra för att ändra ordning",
"clear_search": "Rensa sökning",
"learn_more": "Läs mer",

View File

@ -351,6 +351,7 @@
"rbf_title": "เพิ่มค่าธรรมเนียม (RBF)",
"status_bump": "เพิ่มค่าธรรมเนียม",
"status_cancel": "ยกเลิกธุรกรรม",
"transactions_count": "จำนวนธุรกรรม",
"cancel_explain": "เราจะแทนที่ธุรกรรมนี้ด้วยธุรกรรมที่จ่ายให้ท่านเองและมีค่าธรรมเนียมสูงขึ้น ซึ่งจะเป็นการยกเลิกธุรกรรมปัจจุบันอย่างมีประสิทธิภาพ วิธีนี้เรียกว่า RBF—Replace by Fee",
"transaction_loading_error": "เกิดปัญหาในการโหลดธุรกรรม กรุณาลองอีกครั้งในภายหลัง",
"transaction_not_available": "ไม่พบธุรกรรม",
@ -474,6 +475,8 @@
"details_export_history": "ส่งออกประวัติเป็น CSV",
"details_multisig_type": "หลายลายเซ็น",
"details_show_addresses": "แสดงแอดเดรส",
"swipe_balance_hide": "ซ่อน",
"swipe_balance_show": "แสดง",
"drag_to_reorder": "ลากเพื่อจัดเรียงใหม่",
"clear_search": "ล้างการค้นหา",
"import_passphrase": "วลีรหัสผ่าน",

View File

@ -377,6 +377,7 @@
"rbf_title": "Hızlandır (RBF)",
"status_bump": "Hızlandır",
"status_cancel": "İptal",
"transactions_count": "İşlem Sayısı",
"txid": "İşlem ID'si",
"updating": "Güncelleniyor...",
"watchOnlyWarningTitle": "Güvenlik uyarısı",
@ -462,6 +463,8 @@
"details_master_fingerprint": "Ana Parmak İzi",
"details_multisig_type": "multisig",
"details_show_addresses": "Adresleri göster",
"swipe_balance_hide": "Gizle",
"swipe_balance_show": "Göster",
"drag_to_reorder": "Yeniden sıralamak için sürükle",
"clear_search": "Aramayı temizle",
"details_use_with_hardware_wallet": "Donanım Cüzdanı ile Kullan",

View File

@ -376,6 +376,7 @@
"rbf_title": "Прискорити (RBF)",
"status_bump": "Прискорити",
"status_cancel": "Скасувати",
"transactions_count": "Кількість Транзакцій",
"txid": "ID транзакції",
"updating": "Оновлення...",
"watchOnlyWarningTitle": "Попередження безпеки",
@ -445,6 +446,8 @@
"details_show_addresses": "Показати адреси",
"details_title": "Гаманець",
"wallets": "Гаманці",
"swipe_balance_hide": "Приховати",
"swipe_balance_show": "Показати",
"drag_to_reorder": "Перетягніть, щоб змінити порядок",
"clear_search": "Очистити пошук",
"details_type": "Тип",

View File

@ -366,6 +366,7 @@
"rbf_title": "Tăng phí (RBF)",
"status_bump": "Tăng phí",
"status_cancel": "Huỷ giao dịch",
"transactions_count": "Số lượng giao dịch ",
"txid": "ID giao dịch",
"updating": "Đang cập nhật...",
"transaction_loading_error": "Đã có sự cố khi tải giao dịch. Vui lòng thử lại sau.",
@ -489,6 +490,8 @@
"clear_clipboard_on_import": "Xoá bảng tạm khi nhập",
"details_del_wb_err": "Số dư được cung cấp không khớp với số dư của ví này. Vui lòng thử lại.",
"details_display": "Hiển thị ở màn hình chính",
"swipe_balance_hide": "Ẩn",
"swipe_balance_show": "Hiển thị",
"drag_to_reorder": "Kéo để sắp xếp lại",
"clear_search": "Xoá tìm kiếm",
"learn_more": "Tìm hiểu thêm",

View File

@ -376,6 +376,7 @@
"rbf_title": "Versnel (RBF)",
"status_bump": "Versnel",
"status_cancel": "Kanselleer",
"transactions_count": "Transaksietelling",
"txid": "Transaksie-ID",
"updating": "Opdateer...",
"watchOnlyWarningTitle": "Sekuriteitswaarskuwing",
@ -445,6 +446,8 @@
"details_show_addresses": "Wys adresse",
"details_title": "Beursie",
"wallets": "beursies",
"swipe_balance_hide": "Versteek",
"swipe_balance_show": "Wys",
"drag_to_reorder": "Sleep om te herrangskik",
"clear_search": "Maak soektog skoon",
"details_type": "Tipe",

View File

@ -375,6 +375,7 @@
"rbf_title": "Phakamisa Intlawulo (RBF)",
"status_bump": "Phakamisa Intlawulo",
"status_cancel": "Rhoxisa",
"transactions_count": "Inani lee-Transaction",
"txid": "I-ID ye-Transaction",
"updating": "Iyahlaziya...",
"watchOnlyWarningTitle": "Isilumkiso sokhuseleko",
@ -442,6 +443,8 @@
"details_master_fingerprint": "I-Fingerprint engundoqo",
"details_multisig_type": "i-multisig",
"details_show_addresses": "Bonisa iidilesi",
"swipe_balance_hide": "Fihla",
"swipe_balance_show": "Bonisa",
"drag_to_reorder": "Tsala ukuhlela kwakhona",
"clear_search": "Cima ukhangelo",
"details_use_with_hardware_wallet": "Sebenzisa ne-Hardware Wallet",

View File

@ -376,6 +376,7 @@
"rbf_title": "追加矿工费RBF",
"status_bump": "追加矿工费",
"status_cancel": "取消交易",
"transactions_count": "交易数量",
"txid": "交易 ID",
"updating": "正在更新……",
"watchOnlyWarningTitle": "安全警告",
@ -513,6 +514,8 @@
"more_info": "更多信息",
"details_delete_wallet_error_message": "确认此钱包是否已从通知中移除时出现问题——这可能是由于网络问题或连接不良。如果您继续操作,即使删除此钱包,您仍可能收到与该钱包相关的交易通知。",
"details_delete_anyway": "仍然删除",
"swipe_balance_hide": "隐藏",
"swipe_balance_show": "显示",
"drag_to_reorder": "拖动以重新排序",
"clear_search": "清除搜索"
},

View File

@ -354,6 +354,7 @@
"rbf_title": "對碰費用RBF",
"status_bump": "對碰費用",
"status_cancel": "取消交易",
"transactions_count": "交易記數",
"txid": "交易ID",
"updating": "更新中...",
"cancel_explain": "我們將以一筆付款給您本人且手續費較高的交易來取代此交易。這實際上會取消目前的交易。此功能稱為 RBF—以手續費取代。",
@ -481,6 +482,8 @@
"details_del_wb_q": "此錢包有結餘。在繼續之前,請注意若沒有此錢包的助記詞,您將無法復原資金。為避免意外移除,請輸入您錢包的結餘 {balance} 聰。",
"details_display": "顯示於主畫面",
"details_export_history": "匯出紀錄為 CSV",
"swipe_balance_hide": "隱藏",
"swipe_balance_show": "顯示",
"drag_to_reorder": "拖曳以重新排序",
"clear_search": "清除搜尋",
"import_passphrase": "密語",

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 { NativeStackNavigationOptions } from '@react-navigation/native-stack';
import type { NativeStackHeaderItem, NativeStackNavigationOptions } from '@react-navigation/native-stack';
import navigationStyle, { CloseButtonPosition } from '../components/navigationStyle';
import { useTheme } from '../components/themes';
import { useExtendedNavigation } from '../hooks/useExtendedNavigation';
@ -57,6 +57,9 @@ 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);
@ -150,6 +153,15 @@ const DetailViewStackScreensStack = () => {
navigation.navigate('AddWalletRoot');
}, [navigation]);
const navigateToSettings = useCallback(() => {
navigation.navigate('DrawerRoot', {
screen: 'DetailViewStackScreensStack',
params: {
screen: 'Settings',
},
});
}, [navigation]);
const RightBarButtons = useMemo(
() =>
sizeClass === SizeClass.Large ? (
@ -219,6 +231,53 @@ 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,
@ -233,6 +292,7 @@ const DetailViewStackScreensStack = () => {
RightBarButtons,
sizeClass,
theme.colors.customHeader,
theme.colors.headerProminentButtonBackgroundColor,
theme.colors.foregroundColor,
theme.colors.lightButton,
theme.colors.redBG,
@ -242,6 +302,8 @@ const DetailViewStackScreensStack = () => {
electrumConnected,
isElectrumDisabled,
navigateToElectrumSettings,
navigateToAddWallet,
navigateToSettings,
walletTransactionUpdateStatus,
]);
@ -251,6 +313,14 @@ 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
@ -273,6 +343,9 @@ const DetailViewStackScreensStack = () => {
};
};
const settingsScreenOptions = (title: string) =>
isIOS26OrHigher ? getSettingsHeaderOptions(title) : navigationStyle(getSettingsHeaderOptions(title))(theme);
return (
<ConnectionPollContext.Provider value={connectionPollContextValue}>
<DetailViewStack.Navigator
@ -339,22 +412,14 @@ const DetailViewStackScreensStack = () => {
options={navigationStyle({ title: loc.lndViewInvoice.additional_info })(theme)}
/>
<DetailViewStack.Screen
name="Broadcast"
component={Broadcast}
options={navigationStyle(getSettingsHeaderOptions(loc.send.create_broadcast))(theme)}
/>
<DetailViewStack.Screen name="Broadcast" component={Broadcast} options={settingsScreenOptions(loc.send.create_broadcast)} />
<DetailViewStack.Screen
name="IsItMyAddress"
component={IsItMyAddress}
initialParams={{ address: undefined }}
options={navigationStyle(getSettingsHeaderOptions(loc.is_it_my_address.title))(theme)}
/>
<DetailViewStack.Screen
name="GenerateWord"
component={GenerateWord}
options={navigationStyle(getSettingsHeaderOptions(loc.autofill_word.title))(theme)}
options={settingsScreenOptions(loc.is_it_my_address.title)}
/>
<DetailViewStack.Screen name="GenerateWord" component={GenerateWord} options={settingsScreenOptions(loc.autofill_word.title)} />
<DetailViewStack.Screen
name="LnurlPay"
component={LnurlPay}
@ -397,115 +462,90 @@ const DetailViewStackScreensStack = () => {
<DetailViewStack.Screen
name="Settings"
component={Settings}
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'
? {
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: {
color:
typeof theme.colors.foregroundColor === 'string'
? theme.colors.foregroundColor
: String(theme.colors.foregroundColor),
}
: 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)}
},
headerTransparent: false,
headerBlurEffect: undefined,
headerStyle: {
backgroundColor: settingsHeaderBackgroundColor,
},
animationTypeForReplace: 'push',
})(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={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)}
options={settingsScreenOptions(loc.plausibledeniability.title)}
/>
<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={navigationStyle(getSettingsHeaderOptions(loc.settings.block_explorer))(theme)}
options={settingsScreenOptions(loc.settings.block_explorer)}
/>
<DetailViewStack.Screen
name="About"
component={About}
options={navigationStyle(getSettingsHeaderOptions(loc.settings.about))(theme)}
/>
<DetailViewStack.Screen name="About" component={About} options={settingsScreenOptions(loc.settings.about)} />
{/* <DetailViewStack.Screen
name="DefaultView"
component={DefaultView}
options={navigationStyle(getSettingsHeaderOptions(loc.settings.default_title))(theme)}
options={settingsScreenOptions(loc.settings.default_title)}
/> */}
<DetailViewStack.Screen
name="ElectrumSettings"
component={ElectrumSettings}
options={navigationStyle(getSettingsHeaderOptions(loc.settings.electrum_settings_server))(theme)}
options={settingsScreenOptions(loc.settings.electrum_settings_server)}
initialParams={{ server: undefined }}
/>
<DetailViewStack.Screen
name="EncryptStorage"
component={EncryptStorage}
options={navigationStyle(getSettingsHeaderOptions(loc.settings.encrypt_title))(theme)}
/>
<DetailViewStack.Screen
name="Language"
component={Language}
options={navigationStyle(getSettingsHeaderOptions(loc.settings.language))(theme)}
options={settingsScreenOptions(loc.settings.encrypt_title)}
/>
<DetailViewStack.Screen name="Language" component={Language} options={settingsScreenOptions(loc.settings.language)} />
<DetailViewStack.Screen
name="LightningSettings"
component={LightningSettings}
options={navigationStyle(getSettingsHeaderOptions(loc.settings.lightning_settings))(theme)}
options={settingsScreenOptions(loc.settings.lightning_settings)}
/>
<DetailViewStack.Screen
name="NotificationSettings"
component={NotificationSettings}
options={navigationStyle(getSettingsHeaderOptions(loc.settings.notifications))(theme)}
/>
<DetailViewStack.Screen
name="SelfTest"
component={SelfTest}
options={navigationStyle(getSettingsHeaderOptions(loc.settings.selfTest))(theme)}
options={settingsScreenOptions(loc.settings.notifications)}
/>
<DetailViewStack.Screen name="SelfTest" component={SelfTest} options={settingsScreenOptions(loc.settings.selfTest)} />
<DetailViewStack.Screen
name="ReleaseNotes"
component={ReleaseNotes}
options={navigationStyle(getSettingsHeaderOptions(loc.settings.about_release_notes))(theme)}
/>
<DetailViewStack.Screen
name="SettingsTools"
component={SettingsTools}
options={navigationStyle(getSettingsHeaderOptions(loc.settings.tools))(theme)}
options={settingsScreenOptions(loc.settings.about_release_notes)}
/>
<DetailViewStack.Screen name="SettingsTools" component={SettingsTools} options={settingsScreenOptions(loc.settings.tools)} />
<DetailViewStack.Screen
name="PromptPasswordConfirmationSheet"
component={PromptPasswordConfirmationSheet}

View File

@ -1,44 +1,98 @@
import React from 'react';
import { TouchableOpacity, StyleSheet } from 'react-native';
import { Platform, TouchableOpacity, StyleSheet } from 'react-native';
import type { NativeStackHeaderItem, NativeStackNavigationOptions } from '@react-navigation/native-stack';
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 getWalletTransactionsOptions = ({ route }: { route: WalletTransactionsRouteProps }): NativeStackNavigationOptions => {
const { isLoading = false, walletID, walletType } = route.params;
const HERO_HEADER_ICON_COLOR = '#FFFFFF';
const onPress = () => {
navigationRef.navigate('WalletDetails', {
walletID,
});
};
const navigateToWalletDetails = (walletID: string) => {
navigationRef.navigate('WalletDetails', {
walletID,
});
};
const RightButton = (
<TouchableOpacity accessibilityRole="button" testID="WalletDetails" disabled={isLoading} style={styles.walletDetails} onPress={onPress}>
<Icon name="more-horiz" type="material" size={22} color="#FFFFFF" />
/** 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} />
</TouchableOpacity>
);
};
const backgroundColor = WalletGradient.headerColorFor(walletType);
/** 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,
},
];
};
return {
const getWalletTransactionsOptions = ({ route }: { route: WalletTransactionsRouteProps }): NativeStackNavigationOptions => {
const { isLoading = false, walletID } = route.params;
const base: NativeStackNavigationOptions = {
title: '',
headerBackTitleStyle: { fontSize: 0 },
headerTransparent: true,
headerStyle: {
backgroundColor,
backgroundColor: 'transparent',
},
headerBackButtonDisplayMode: 'minimal',
headerShadowVisible: false,
headerTintColor: '#FFFFFF',
headerTintColor: HERO_HEADER_ICON_COLOR,
headerBlurEffect: undefined,
statusBarStyle: 'light',
headerBackTitle: undefined,
headerRight: () => RightButton,
headerRight: createWalletDetailsHeaderRight({ walletID, isLoading, iconColor: HERO_HEADER_ICON_COLOR }),
};
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({

32
package-lock.json generated
View File

@ -17,7 +17,8 @@
"@bugsnag/source-maps": "2.3.3",
"@keystonehq/bc-ur-registry": "0.7.1",
"@ngraveio/bc-ur": "1.1.13",
"@noble/hashes": "1.3.3",
"@noble/ciphers": "1.3.0",
"@noble/hashes": "1.8.0",
"@noble/secp256k1": "3.1.0",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-clipboard/clipboard": "1.16.3",
@ -57,7 +58,6 @@
"buffer": "6.0.3",
"coinselect": "github:BlueWallet/coinselect#35f8038",
"crypto-browserify": "3.12.1",
"crypto-js": "4.2.0",
"dayjs": "1.11.21",
"detox": "20.51.3",
"ecpair": "3.0.1",
@ -126,7 +126,6 @@
"@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",
@ -3497,6 +3496,18 @@
"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",
@ -3525,10 +3536,12 @@
}
},
"node_modules/@noble/hashes": {
"version": "1.3.3",
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"license": "MIT",
"engines": {
"node": ">= 16"
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
@ -5263,11 +5276,6 @@
"@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,
@ -7955,10 +7963,6 @@
"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",

View File

@ -27,7 +27,6 @@
"@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",
@ -100,7 +99,8 @@
"@bugsnag/source-maps": "2.3.3",
"@keystonehq/bc-ur-registry": "0.7.1",
"@ngraveio/bc-ur": "1.1.13",
"@noble/hashes": "1.3.3",
"@noble/ciphers": "1.3.0",
"@noble/hashes": "1.8.0",
"@noble/secp256k1": "3.1.0",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-clipboard/clipboard": "1.16.3",
@ -140,7 +140,6 @@
"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

@ -0,0 +1,84 @@
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,3 +64,48 @@ 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

@ -0,0 +1,12 @@
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

@ -2,7 +2,13 @@ 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 } from '../../components/platform';
import {
SettingsScrollView,
SettingsSection,
SettingsListItem,
getSettingsHeaderOptions,
isIOS26OrHigher,
} from '../../components/platform';
import { useSettings } from '../../hooks/context/useSettings';
import { useTheme } from '../../components/themes';
@ -15,6 +21,9 @@ 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 { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { NativeStackNavigationOptions, NativeStackNavigationProp } from '@react-navigation/native-stack';
import Icon from '../../components/Icon';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
@ -63,6 +63,10 @@ enum ButtonStatus {
type RouteProps = RouteProp<DetailViewStackParamList, 'TransactionStatus'>;
type NavigationProps = NativeStackNavigationProp<DetailViewStackParamList, 'TransactionStatus'>;
type TransactionStatusHeaderOptions = NativeStackNavigationOptions & {
headerTitleContainerStyle?: { flex: number; maxWidth: number };
};
enum ActionType {
SetCPFPPossible,
SetRBFBumpFeePossible,
@ -136,8 +140,12 @@ type TransactionDetailHeaderTitleProps = {
const TransactionDetailHeaderTitle: React.FC<TransactionDetailHeaderTitleProps> = ({ direction, date, directionStyle, dateStyle }) => (
<View style={styles.headerTitleContainer}>
<BlueText style={directionStyle}>{direction}</BlueText>
<BlueText style={dateStyle}>{date}</BlueText>
<BlueText style={directionStyle} numberOfLines={1} adjustsFontSizeToFit minimumFontScale={0.8}>
{direction}
</BlueText>
<BlueText style={dateStyle} numberOfLines={2} adjustsFontSizeToFit minimumFontScale={0.8}>
{date}
</BlueText>
</View>
);
@ -153,10 +161,57 @@ const TransactionStatus: React.FC = () => {
const subscribedWallet = useWalletSubscribe(walletID);
const { navigate, goBack, setOptions } = useExtendedNavigation<NavigationProps>();
const { colors } = useTheme();
const { width: windowWidth } = useWindowDimensions();
const { width: windowWidth, fontScale } = 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]);
@ -921,15 +976,20 @@ const TransactionStatus: React.FC = () => {
<TransactionDetailHeaderTitle
direction={transactionDirection}
date={transactionDate}
directionStyle={[styles.headerTitleDirection, stylesHook.headerTitleDirection]}
dateStyle={[styles.headerTitleDate, stylesHook.titleDate]}
directionStyle={[styles.headerTitleDirection, stylesHook.headerTitleDirection, scaledStyles.headerTitleDirection]}
dateStyle={[styles.headerTitleDate, stylesHook.titleDate, scaledStyles.headerTitleDate]}
/>
),
});
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]);
}, [tx, transactionDirection, transactionDate, setOptions, colors, windowWidth, scaledStyles]);
if (loadingError) {
return (
@ -962,15 +1022,20 @@ const TransactionStatus: React.FC = () => {
{/* Value Section */}
<View style={styles.valueCard}>
<View style={styles.valueContent}>
<Text style={[styles.value, stylesHook.value]} selectable numberOfLines={1} adjustsFontSizeToFit minimumFontScale={0.55}>
<Text
style={[styles.value, stylesHook.value, scaledStyles.value, styles.valueFullWidth]}
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]}>
<Text style={[styles.localCurrency, stylesHook.localCurrency, scaledStyles.localCurrency]}>
{preferredBalanceUnit === BitcoinUnit.LOCAL_CURRENCY
? `${formatBalanceWithoutSuffix(Math.abs(txValue), BitcoinUnit.BTC, true)} ${BitcoinUnit.BTC}`
: satoshiToLocalCurrency(Math.abs(txValue))}
@ -996,8 +1061,10 @@ const TransactionStatus: React.FC = () => {
<View style={styles.stateIndicator}>
<TransactionPendingIcon />
<View style={styles.stateLabelContainer}>
<BlueText style={[styles.stateLabel, stylesHook.stateLabelPending]}>{loc.transactions.pending}</BlueText>
<BlueText style={[styles.stateValue, stylesHook.stateValuePending, styles.stateValueInline]}>
<BlueText style={[styles.stateLabel, stylesHook.stateLabelPending, scaledStyles.stateLabel]}>
{loc.transactions.pending}
</BlueText>
<BlueText style={[styles.stateValue, stylesHook.stateValuePending, styles.stateValueInline, scaledStyles.stateValue]}>
{eta || loc.transactions.details_eta_analyzing}
</BlueText>
</View>
@ -1029,9 +1096,11 @@ const TransactionStatus: React.FC = () => {
<View style={styles.stateIndicator}>
<TransactionOutgoingIcon />
<View style={styles.stateLabelContainer}>
<BlueText style={[styles.stateLabel, stylesHook.stateLabelSent]}>{loc.transactions.details_sent}</BlueText>
<BlueText style={[styles.stateLabel, stylesHook.stateLabelSent, scaledStyles.stateLabel]}>
{loc.transactions.details_sent}
</BlueText>
{isOnChainTx && (
<BlueText style={[styles.stateValue, stylesHook.stateValueSent, styles.stateValueInline]}>
<BlueText style={[styles.stateValue, stylesHook.stateValueSent, styles.stateValueInline, scaledStyles.stateValue]}>
{loc.formatString(loc.transactions.confirmations_lowercase, {
confirmations: parsedConfirmations > 6 ? '6+' : parsedConfirmations,
})}
@ -1043,9 +1112,11 @@ const TransactionStatus: React.FC = () => {
<View style={styles.stateIndicator}>
<TransactionIncomingIcon />
<View style={styles.stateLabelContainer}>
<BlueText style={[styles.stateLabel, stylesHook.stateLabelReceived]}>{loc.transactions.details_received}</BlueText>
<BlueText style={[styles.stateLabel, stylesHook.stateLabelReceived, scaledStyles.stateLabel]}>
{loc.transactions.details_received}
</BlueText>
{isOnChainTx && (
<BlueText style={[styles.stateValue, stylesHook.stateValueReceived, styles.stateValueInline]}>
<BlueText style={[styles.stateValue, stylesHook.stateValueReceived, styles.stateValueInline, scaledStyles.stateValue]}>
{loc.formatString(loc.transactions.confirmations_lowercase, {
confirmations: parsedConfirmations > 6 ? '6+' : parsedConfirmations,
})}
@ -1080,20 +1151,29 @@ const TransactionStatus: React.FC = () => {
{/* Details Section */}
<View style={[styles.detailsCard, stylesHook.detailsCard]}>
{/* Details Title */}
<View style={[styles.sectionTitle, styles.sectionTitleWithButton, stylesHook.sectionTitle]}>
<BlueText style={[styles.sectionTitleText, stylesHook.sectionTitleText]}>{loc.transactions.details_section}</BlueText>
<View style={[styles.sectionTitle, styles.sectionTitleWithButton, stylesHook.sectionTitle, scaledStyles.sectionTitle]}>
<BlueText style={[styles.sectionTitleText, stylesHook.sectionTitleText, styles.sectionTitleTextFlexible]}>
{loc.transactions.details_section}
</BlueText>
{tx?.hash && (
<TouchableOpacity
onPress={handleOpenBlockExplorer}
style={[styles.explorerButton, stylesHook.explorerButton]}
style={[styles.explorerButton, stylesHook.explorerButton, scaledStyles.explorerButton]}
activeOpacity={0.7}
>
<BlueText style={[styles.explorerButtonText, stylesHook.explorerButtonText]}>{loc.transactions.details_explorer}</BlueText>
<BlueText
style={[styles.explorerButtonText, stylesHook.explorerButtonText]}
numberOfLines={1}
adjustsFontSizeToFit
minimumFontScale={0.8}
>
{loc.transactions.details_explorer}
</BlueText>
</TouchableOpacity>
)}
</View>
{/* Network Fee */}
<View style={[styles.detailRow, stylesHook.detailRow]}>
<View style={[styles.detailRow, stylesHook.detailRow, scaledStyles.detailRow]}>
<BlueText style={[styles.detailLabel, stylesHook.detailLabel]}>{loc.transactions.details_network_fee}</BlueText>
<View style={styles.detailValueContainer}>
<CopyTextToClipboard
@ -1117,7 +1197,7 @@ const TransactionStatus: React.FC = () => {
const displayText = externalAddresses.map(shortenCounterpartyName).join(', ');
const copyText = externalAddresses.join(', ');
return (
<View style={[styles.detailRow, stylesHook.detailRow]}>
<View style={[styles.detailRow, stylesHook.detailRow, scaledStyles.detailRow]}>
<BlueText style={[styles.detailLabel, stylesHook.detailLabel]}>{loc.transactions.details_to_address}</BlueText>
<View style={styles.detailValueContainer}>
<View style={styles.detailValueCopyContainer}>
@ -1143,7 +1223,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]}>
<View style={[styles.detailRow, stylesHook.detailRow, scaledStyles.detailRow]}>
<BlueText style={[styles.detailLabel, stylesHook.detailLabel]}>{loc.transactions.details_id}</BlueText>
<View style={styles.detailValueContainer}>
<View style={styles.detailValueCopyContainer}>
@ -1170,7 +1250,7 @@ const TransactionStatus: React.FC = () => {
)}
{/* Note/Memo */}
<View style={[styles.detailRow, styles.detailRowLast, stylesHook.detailRow]}>
<View style={[styles.detailRow, styles.detailRowLast, stylesHook.detailRow, scaledStyles.detailRow]}>
<BlueText style={[styles.detailLabel, stylesHook.detailLabel]}>{loc.transactions.details_note}</BlueText>
<View style={styles.detailValueContainer}>
{memo ? (
@ -1180,8 +1260,19 @@ const TransactionStatus: React.FC = () => {
</BlueText>
</TouchableOpacity>
) : (
<TouchableOpacity onPress={handleNotePress} style={[styles.addButton, stylesHook.addButton]} activeOpacity={0.7}>
<BlueText style={[styles.addButtonText, stylesHook.addButtonText]}>{loc.transactions.details_add_note}</BlueText>
<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>
)}
</View>
@ -1192,11 +1283,13 @@ const TransactionStatus: React.FC = () => {
<View style={[styles.detailsCard, stylesHook.detailsCard]}>
<TouchableOpacity
onPress={() => setIsAdvancedExpanded(!isAdvancedExpanded)}
style={[styles.advancedHeader, stylesHook.advancedHeader]}
style={[styles.advancedHeader, stylesHook.advancedHeader, scaledStyles.advancedHeader]}
activeOpacity={0.85}
>
<View style={[styles.sectionTitle, stylesHook.sectionTitle, styles.sectionTitleRow]}>
<BlueText style={[styles.sectionTitleText, stylesHook.sectionTitleText]}>{loc.transactions.details_advanced}</BlueText>
<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>
<Icon
name={isAdvancedExpanded ? 'chevron-up' : 'chevron-down'}
type="font-awesome"
@ -1209,7 +1302,7 @@ const TransactionStatus: React.FC = () => {
{isAdvancedExpanded && (
<View style={[styles.advancedContent, stylesHook.advancedContent]}>
{/* Fee Rate */}
<View style={[styles.detailRow, stylesHook.detailRow]}>
<View style={[styles.detailRow, stylesHook.detailRow, scaledStyles.detailRow]}>
<BlueText style={[styles.detailLabel, stylesHook.detailLabel]}>{loc.transactions.details_fee_rate}</BlueText>
<View style={styles.detailValueContainer}>
<CopyTextToClipboard
@ -1221,7 +1314,7 @@ const TransactionStatus: React.FC = () => {
</View>
{/* Size */}
<View style={[styles.detailRow, stylesHook.detailRow]}>
<View style={[styles.detailRow, stylesHook.detailRow, scaledStyles.detailRow]}>
<BlueText style={[styles.detailLabel, stylesHook.detailLabel]}>{loc.transactions.details_size}</BlueText>
<View style={styles.detailValueContainer}>
<CopyTextToClipboard
@ -1233,7 +1326,7 @@ const TransactionStatus: React.FC = () => {
</View>
{/* Virtual Size */}
<View style={[styles.detailRow, stylesHook.detailRow]}>
<View style={[styles.detailRow, stylesHook.detailRow, scaledStyles.detailRow]}>
<BlueText style={[styles.detailLabel, stylesHook.detailLabel]}>{loc.transactions.details_virtual_size}</BlueText>
<View style={styles.detailValueContainer}>
<CopyTextToClipboard
@ -1245,7 +1338,7 @@ const TransactionStatus: React.FC = () => {
</View>
{/* Transaction Hex */}
<View style={[styles.detailRow, stylesHook.detailRow]}>
<View style={[styles.detailRow, stylesHook.detailRow, scaledStyles.detailRow]}>
<BlueText style={[styles.detailLabel, stylesHook.detailLabel]}>{loc.transactions.details_tx_hex}</BlueText>
<View style={styles.detailValueContainer}>
{txHex ? (
@ -1310,6 +1403,7 @@ const styles = StyleSheet.create({
alignItems: 'flex-start',
justifyContent: 'center',
flex: 1,
minWidth: 0,
},
headerTitleDirection: {
fontSize: 17,
@ -1357,15 +1451,20 @@ const styles = StyleSheet.create({
alignItems: 'flex-start',
justifyContent: 'flex-start',
overflow: 'visible',
width: '100%',
},
value: {
fontSize: 40,
fontWeight: '700',
letterSpacing: -0.5,
lineHeight: 32,
lineHeight: 48,
paddingTop: 8,
minHeight: 38,
},
valueFullWidth: {
width: '100%',
flexShrink: 1,
},
valueUnit: {
fontSize: 18,
fontWeight: '600',
@ -1383,7 +1482,6 @@ const styles = StyleSheet.create({
borderRadius: 12,
marginHorizontal: 24,
marginBottom: 42,
overflow: 'hidden',
},
stateSection: {
alignItems: 'flex-start',
@ -1401,6 +1499,7 @@ const styles = StyleSheet.create({
alignItems: 'flex-start',
marginLeft: 8,
flex: 1,
minWidth: 0,
},
stateLabel: {
fontSize: 16,
@ -1486,17 +1585,23 @@ 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',
minWidth: 50,
flexShrink: 0,
alignItems: 'center',
justifyContent: 'center',
},
@ -1507,7 +1612,7 @@ const styles = StyleSheet.create({
detailRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
alignItems: 'flex-start',
marginBottom: 0,
minHeight: 24,
paddingVertical: 12,
@ -1531,6 +1636,8 @@ const styles = StyleSheet.create({
fontSize: 16,
fontWeight: '500',
flex: 1,
flexShrink: 1,
minWidth: 0,
lineHeight: 22,
paddingRight: 12,
},
@ -1544,11 +1651,12 @@ const styles = StyleSheet.create({
flex: 1,
minWidth: 0,
maxWidth: '100%',
flexWrap: 'nowrap',
alignItems: 'center',
flexWrap: 'wrap',
alignItems: 'flex-end',
flexDirection: 'row',
justifyContent: 'flex-end',
gap: 8,
flexShrink: 0,
},
detailValueCopyContainer: {
flex: 1,
@ -1596,7 +1704,7 @@ const styles = StyleSheet.create({
paddingHorizontal: 12,
borderRadius: 6,
alignSelf: 'flex-end',
minWidth: 50,
flexShrink: 0,
alignItems: 'center',
justifyContent: 'center',
},
@ -1614,7 +1722,6 @@ const styles = StyleSheet.create({
borderWidth: 1,
borderTopLeftRadius: 12,
borderTopRightRadius: 12,
overflow: 'hidden',
},
advancedContent: {
marginTop: 0,

View File

@ -20,9 +20,6 @@ import { useTheme } from '../../components/themes';
import { useExtendedNavigation } from '../../hooks/useExtendedNavigation';
import loc from '../../loc';
import { useStorage } from '../../hooks/context/useStorage';
import { useSettings } from '../../hooks/context/useSettings';
import { BitcoinUnit } from '../../models/bitcoinUnits';
import { FiatUnit } from '../../models/fiatUnit';
import { TTXMetadata } from '../../class/blue-app';
import { ExtendedTransaction, LightningTransaction, Transaction, TWallet } from '../../class/wallets/types';
import useBounceAnimation from '../../hooks/useBounceAnimation';
@ -130,8 +127,6 @@ const reducer = (state: State, action: Action): State => {
const ManageWallets: React.FC = () => {
const { colors, closeImage, dark } = useTheme();
const { wallets: persistedWallets, setWalletsWithNewOrder, txMetadata } = useStorage();
const { preferredFiatCurrency } = useSettings();
const preferredFiatLabel = preferredFiatCurrency?.endPointKey ?? FiatUnit.USD.endPointKey;
const initialWalletsRef = useRef<TWallet[]>(deepCopyWallets(persistedWallets));
const { navigate, setOptions, goBack } = useExtendedNavigation();
const { direction } = useLocale();
@ -149,7 +144,6 @@ const ManageWallets: React.FC = () => {
const [noResultsOpacity] = useState(new Animated.Value(0));
const [dragging, setDragging] = useState(false);
const [resetSwipeToken, setResetSwipeToken] = useState(0);
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const debouncedSearch = useCallback((text: string) => {
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
@ -502,33 +496,6 @@ const ManageWallets: React.FC = () => {
[state.walletsCopy, setWalletsWithNewOrder],
);
const handleCycleBalanceUnit = useCallback(
(wallet: TWallet) => {
const walletID = wallet.getID();
const current = wallet.getPreferredBalanceUnit();
let next: BitcoinUnit;
if (current === BitcoinUnit.BTC) {
next = BitcoinUnit.SATS;
} else if (current === BitcoinUnit.SATS) {
next = BitcoinUnit.LOCAL_CURRENCY;
} else {
next = BitcoinUnit.BTC;
}
const updatedWallets = deepCopyWallets(state.walletsCopy).map(w => {
if (w.getID() === walletID) {
w.setPreferredBalanceUnit(next);
}
return w;
});
setWalletsWithNewOrder(updatedWallets);
dispatch({ type: SAVE_CHANGES, payload: updatedWallets });
triggerHapticFeedback(HapticFeedbackTypes.Selection);
},
[state.walletsCopy, setWalletsWithNewOrder],
);
const renderListItem = useCallback(
(item: Item, drag: (() => void) | undefined, isActive: boolean) => {
const compatibleState = {
@ -551,25 +518,11 @@ const ManageWallets: React.FC = () => {
);
}
let rowBaseKey = '';
if (item.type === ItemType.WalletSection) {
rowBaseKey = `wallet-${item.data.getID()}`;
} else if (item.type === ItemType.TransactionSection) {
const paymentHash =
typeof item.data.payment_hash === 'string' ? item.data.payment_hash : item.data.payment_hash?.data?.toString?.() || '';
rowBaseKey = `tx-${item.data.hash || item.data.txid || paymentHash || item.data.timestamp}-${item.data.walletID || ''}`;
} else {
rowBaseKey = `addr-${item.data.address}-${item.data.walletID}-${item.data.index}`;
}
return (
<ManageWalletsListItem
key={`row-${resetSwipeToken}-${rowBaseKey}`}
item={item}
isDraggingDisabled={isDragDisabled}
handleToggleHideBalance={handleToggleHideBalance}
handleCycleBalanceUnit={handleCycleBalanceUnit}
preferredFiatLabel={preferredFiatLabel}
state={compatibleState}
navigateToWallet={navigateToWallet}
navigateToAddress={navigateToAddress}
@ -582,12 +535,9 @@ const ManageWallets: React.FC = () => {
},
[
handleToggleHideBalance,
handleCycleBalanceUnit,
preferredFiatLabel,
state.walletsCopy,
state.searchQuery,
state.isSearchFocused,
resetSwipeToken,
navigateToWallet,
navigateToAddress,
renderHighlightedText,
@ -711,7 +661,6 @@ const ManageWallets: React.FC = () => {
containerStyle={styles.listContainer}
onDragBegin={() => {
setDragging(true);
setResetSwipeToken(prev => prev + 1);
}}
onDragEnd={({ from, to, data }: DragEndParams<Item>) => {
setDragging(false);

View File

@ -11,9 +11,15 @@ 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';
@ -27,6 +33,7 @@ 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';
@ -35,10 +42,14 @@ import { Chain } from '../../models/bitcoinUnits';
import ActionSheet from '../ActionSheet';
import { useStorage } from '../../hooks/context/useStorage';
import WatchOnlyWarning from '../../components/WatchOnlyWarning';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { NativeStackNavigationOptions, NativeStackScreenProps } from '@react-navigation/native-stack';
import { DetailViewStackParamList } from '../../navigation/DetailViewStackParamList';
import { Transaction, TWallet } from '../../class/wallets/types';
import getWalletTransactionsOptions, { WalletTransactionsRouteProps } from '../../navigation/helpers/getWalletTransactionsOptions';
import getWalletTransactionsOptions, {
WalletTransactionsRouteProps,
createWalletDetailsHeaderRight,
createWalletDetailsHeaderRightItems,
} from '../../navigation/helpers/getWalletTransactionsOptions';
import { presentWalletExportReminder } from '../../helpers/presentWalletExportReminder';
import selectWallet from '../../helpers/select-wallet';
import assert from 'assert';
@ -49,6 +60,8 @@ 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
@ -59,7 +72,109 @@ type RouteProps = RouteProp<DetailViewStackParamList, 'WalletTransactions'>;
type WalletTransactionsProps = NativeStackScreenProps<DetailViewStackParamList, 'WalletTransactions'>;
type TransactionListItem = Transaction & { type: 'transaction' | 'header' };
/** 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>;
};
const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { route: WalletTransactionsRouteProps }) => {
const { wallets, saveToDisk } = useStorage();
const { registerTransactionsHandler, unregisterTransactionsHandler } = useMenuElements();
@ -73,8 +188,11 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
const [pageSize] = useState(20);
const navigation = useExtendedNavigation();
const { setOptions, navigate } = navigation;
const { colors } = useTheme();
const { colors, dark } = 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);
@ -87,7 +205,8 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
const MAX_FAILURES = 3;
const flatListRef = useRef<FlatList<Transaction>>(null);
const headerRef = useRef<View>(null);
const [headerHeight, setHeaderHeight] = useState(0);
const headerScrolledRef = useRef(false);
const scrolledHeaderOpacity = useSharedValue(0);
const stylesHook = StyleSheet.create({
listHeaderText: {
@ -100,44 +219,17 @@ 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' }] },
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,
},
}),
sendIcon: {
transform: [{ rotate: direction === 'rtl' ? '-225deg' : '225deg' }],
},
receiveIcon: {
transform: [{ rotate: direction === 'rtl' ? '-45deg' : '45deg' }],
},
});
useFocusEffect(
useCallback(() => {
setOptions(getWalletTransactionsOptions({ route }));
}, [route, setOptions]),
);
const onBarCodeRead = useCallback(
(ret?: { data?: any }) => {
if (!isLoading) {
@ -147,9 +239,15 @@ 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);
}
@ -167,7 +265,6 @@ 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]);
@ -176,10 +273,6 @@ 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);
@ -303,7 +396,10 @@ 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,
});
}
}
@ -342,11 +438,17 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
[name, navigate, navigation, onWalletSelect, walletID, wallets],
);
const getItemLayout = (_: any, index: number) => ({
length: 64,
offset: 64 * index,
index,
});
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 renderItem = useCallback(
// react/no-unused-prop-types misfires on inline arrow renderers: it reads the
@ -391,7 +493,10 @@ 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()) {
@ -493,55 +598,136 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [wallet, wallet.hideBalance, displayUnit, balance]);
const handleScroll = useCallback(
(event: any) => {
const offsetY = event.nativeEvent.contentOffset.y;
const combinedHeight = 180;
if (offsetY < combinedHeight) {
setOptions({ ...getWalletTransactionsOptions({ route }), headerTitle: undefined });
} else {
navigation.setOptions({
headerTitle: `${wallet.getLabel()} ${walletBalance}`,
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;
}
},
[navigation, wallet, walletBalance, setOptions, route],
setOptions(getWalletTransactionsOptions({ route }));
}, [route, screenWidth, scrolledHeaderTitle, scrolledHeaderOpacity, setOptions]),
);
const measureHeaderHeight = useCallback(() => {
if (!headerRef.current) {
// If header ref is not available, use default background
setHeaderHeight(0);
return;
}
const handleScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
const offsetY = event.nativeEvent.contentOffset.y;
const scrolled = offsetY >= SCROLLED_HEADER_SHOW_OFFSET;
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);
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;
}
const fullHeight = pageY + height;
if (fullHeight > 0) {
setHeaderHeight(fullHeight);
if (scrolled === headerScrolledRef.current) return;
headerScrolledRef.current = scrolled;
if (!scrolled) {
setOptions({
...getWalletTransactionsOptions({ route }),
headerTitle: undefined,
headerTitleAlign: undefined,
headerTitleContainerStyle: undefined,
headerBlurEffect: undefined,
});
} else {
setOptions(getScrolledHeaderOptions());
}
});
}, []);
},
[getScrolledHeaderOptions, setOptions, route, screenWidth, scrolledHeaderTitle, scrolledHeaderOpacity],
);
useEffect(() => {
const timer = setTimeout(measureHeaderHeight, 100);
return () => clearTimeout(timer);
}, [walletID, measureHeaderHeight]);
const ListHeaderComponent = useMemo(
const ListHeaderComponent = useCallback(
() => (
<View ref={headerRef} onLayout={measureHeaderHeight}>
<View ref={headerRef}>
<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) {
@ -550,22 +736,25 @@ 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 isShouldBeVisible => {
const isBiometricsEnabled = await isBiometricUseCapableAndEnabled();
if (wallet.hideBalance && isBiometricsEnabled) {
const unlocked = await unlockWithBiometrics();
if (!unlocked) throw new Error('Biometrics failed');
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);
}
wallet.hideBalance = isShouldBeVisible;
await saveToDisk();
}}
onManageFundsPressed={id => {
if (wallet.type === MultisigHDWallet.type) {
@ -591,36 +780,30 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
}
}}
/>
<View style={styles.headerBottomBarSpacer}>
<View style={stylesHook.headerBottomBar} />
<View style={[styles.flex, styles.transactionsSection, stylesHook.backgroundContainer]}>
<View style={styles.listHeaderTextRow}>
<Text style={[styles.listHeaderText, stylesHook.listHeaderText]}>{loc.transactions.list_title}</Text>
</View>
</View>
<View style={stylesHook.backgroundContainer}>
{wallet.type === WatchOnlyWallet.type && isWatchOnlyWarningVisible && (
<WatchOnlyWarning
handleDismiss={() => {
setIsWatchOnlyWarningVisible(false);
wallet.isWatchOnlyWarningVisible = false;
saveToDisk();
}}
/>
)}
</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 style={stylesHook.backgroundContainer}>
{wallet.type === WatchOnlyWallet.type && isWatchOnlyWarningVisible && (
<WatchOnlyWarning
handleDismiss={() => {
setIsWatchOnlyWarningVisible(false);
wallet.isWatchOnlyWarningVisible = false;
saveToDisk();
}}
/>
)}
</View>
</>
</View>
),
[
wallet,
displayUnit,
isUnitSwitching,
measureHeaderHeight,
headerOverlayHeight,
stylesHook.backgroundContainer,
stylesHook.headerBottomBar,
stylesHook.listHeaderText,
saveToDisk,
isBiometricUseCapableAndEnabled,
@ -633,16 +816,18 @@ 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]);
}, [walletID, scrolledHeaderOpacity]);
return (
<View style={[styles.flex, stylesHook.backgroundContainer]}>
<View style={[styles.refreshIndicatorBackground, stylesHook.gradientBackground]} testID="TransactionsListView" />
<View style={[styles.flex, { backgroundColor: WalletGradient.headerColorFor(wallet.type) }]} testID="TransactionsListView">
<FlatList<Transaction>
ref={flatListRef}
style={styles.flatList}
getItemLayout={getItemLayout}
updateCellsBatchingPeriod={50}
onEndReachedThreshold={0.3}
@ -653,8 +838,9 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
keyExtractor={_keyExtractor}
renderItem={renderItem}
initialNumToRender={10}
removeClippedSubviews
contentContainerStyle={stylesHook.backgroundContainer}
removeClippedSubviews={false}
contentContainerStyle={[styles.contentContainer, stylesHook.backgroundContainer]}
contentInsetAdjustmentBehavior="never"
contentInset={{ top: 0, left: 0, bottom: 90, right: 0 }}
maxToRenderPerBatch={10}
onScroll={handleScroll}
@ -671,11 +857,25 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
}
refreshControl={
!isDesktop && !isElectrumDisabled ? (
<RefreshControl refreshing={isLoading} onRefresh={() => refreshTransactions(true)} tintColor={colors.msSuccessCheck} />
<RefreshControl
refreshing={isLoading}
onRefresh={() => refreshTransactions(true)}
tintColor={Platform.OS === 'ios' ? 'transparent' : colors.msSuccessCheck}
progressViewOffset={headerOverlayHeight}
/>
) : 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() && (
@ -684,7 +884,10 @@ 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 });
}
@ -735,22 +938,81 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
export default WalletTransactions;
const styles = StyleSheet.create({
flex: { flex: 1 },
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' },
listHeaderText: { marginTop: 0, marginBottom: 16, fontWeight: 'bold', fontSize: 24 },
refreshIndicatorBackground: {
const scrolledHeaderTitleStyles = StyleSheet.create({
animatedTitleWrapper: {
alignSelf: 'flex-start',
},
iosHeaderRoot: {
height: 44,
justifyContent: 'center',
},
iosTitleArea: {
position: 'absolute',
top: 0,
left: 0,
right: 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,
},
activityIndicator: { marginVertical: 20 },
listHeaderTextRow: {
flex: 1,
marginHorizontal: 16,
flexDirection: 'row',
justifyContent: 'space-between',
},
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,10 +8,15 @@ 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 } from '../../components/FloatButtons';
import { FButton, FContainer, FloatButtonsBottomFade, getFloatingButtonReservedHeight } from '../../components/FloatButtons';
import { useTheme } from '../../components/themes';
import { TransactionListItem } from '../../components/TransactionListItem';
import WalletsCarousel, { getWalletCarouselItemWidth, CarouselListRefType } from '../../components/WalletsCarousel';
import { TX_ROW_BASE_HEIGHT } from '../../components/ListItem';
import WalletsCarousel, {
getWalletCarouselItemWidth,
CarouselListRefType,
getWalletCarouselHeight,
} from '../../components/WalletsCarousel';
import { useSizeClass, SizeClass } from '../../blue_modules/sizeClass';
import loc from '../../loc';
import ActionSheet from '../ActionSheet';
@ -25,8 +30,10 @@ 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;
@ -107,7 +114,11 @@ const WalletsList: React.FC = () => {
const { registerTransactionsHandler, unregisterTransactionsHandler } = useMenuElements();
const { wallets, getTransactions, refreshAllWalletTransactions } = useStorage();
const { isTotalBalanceEnabled, isElectrumDisabled } = useSettings();
const { width } = useWindowDimensions();
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 { colors, scanImage } = useTheme();
const navigation = useExtendedNavigation<NavigationProps>();
const isFocused = useIsFocused();
@ -123,9 +134,11 @@ 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),
},
});
@ -471,7 +484,9 @@ const WalletsList: React.FC = () => {
const sectionListKeyExtractor = useCallback((item: any, index: any) => {
if (typeof item === 'string') return item;
return item?.hash || item?.txid || `${item}${index}`;
const txKey = item?.hash || item?.txid;
if (txKey && item?.walletID) return `${txKey}_${item.walletID}`;
return txKey || `${item}${index}`;
}, []);
const refreshProps = isDesktop || isElectrumDisabled ? {} : { refreshing: isLoading, onRefresh };
@ -490,14 +505,9 @@ 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 SECTION_HEADER_HEIGHT + (sizeClass === SizeClass.Large ? LARGE_TITLE_EXTRA_HEIGHT : 0);
}, [sizeClass]);
return sectionHeaderHeight + (sizeClass === SizeClass.Large ? Math.round(20 * fontScale) : 0);
}, [sizeClass, sectionHeaderHeight, fontScale]);
const getItemLayout = useCallback(
(data: any, index: number) => {
@ -506,8 +516,8 @@ const WalletsList: React.FC = () => {
if (sizeClass === SizeClass.Large) {
// On large screens: only transaction items, no carousel
return {
length: TRANSACTION_ITEM_HEIGHT,
offset: TRANSACTION_ITEM_HEIGHT * index,
length: transactionItemHeight,
offset: transactionItemHeight * index,
index,
};
} else {
@ -515,7 +525,7 @@ const WalletsList: React.FC = () => {
// First section: Carousel
if (index === 0) {
return {
length: CAROUSEL_HEIGHT,
length: carouselHeight,
offset: 0,
index,
};
@ -528,13 +538,13 @@ const WalletsList: React.FC = () => {
// 3. Transaction items
const transactionIndex = index - 1; // Adjust index to account for carousel
return {
length: TRANSACTION_ITEM_HEIGHT,
offset: CAROUSEL_HEIGHT + headerHeight + TRANSACTION_ITEM_HEIGHT * transactionIndex,
length: transactionItemHeight,
offset: carouselHeight + headerHeight + transactionItemHeight * transactionIndex,
index,
};
}
},
[sizeClass, getSectionHeaderHeight],
[sizeClass, getSectionHeaderHeight, carouselHeight, transactionItemHeight],
);
return (
@ -547,11 +557,13 @@ const WalletsList: React.FC = () => {
initialNumToRender={10}
renderSectionFooter={renderSectionFooter}
sections={sections}
floatingButtonHeight={70}
floatingButtonHeight={floatingButtonHeight}
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,6 +4,7 @@ import { element, waitFor } from 'detox';
import {
confirmPasswordDialog,
dismissAlertByText,
expectToBeVisible,
extractTextFromElementById,
goBack,
@ -193,31 +194,36 @@ describe('BlueWallet UI Tests - no wallets', () => {
await waitFor(element(by.id('NotificationsSwitch')))
.toBeVisible()
.withTimeout(10000);
await element(by.id('NotificationsSwitch')).tap();
// If notifications are not enabled on the device, an alert will appear
// 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;
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
}
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
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 goBack();
await goBack();
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();
}
} else {
await goBack();
}
@ -295,34 +301,20 @@ describe('BlueWallet UI Tests - no wallets', () => {
await waitForId('NavigationCloseButton');
await expect(element(by.id('cr34t3d'))).toBeVisible();
// swipe wallet row left to reveal the right action (unit switch); tap it
// Use the label text element as the swipe target: it is typically >90% visible compared to the full row container on CI.
await element(by.text('cr34t3d')).swipe('left', 'slow', 0.6);
await waitForId('SwipeCycleBalanceUnit');
await element(by.id('SwipeCycleBalanceUnit')).tap();
// swipe wallet row right to reveal left action (Hide); tap it
await element(by.text('cr34t3d')).swipe('right', 'slow', 0.6);
// swipe wallet row left to reveal Hide action; tap it
await element(by.id('cr34t3d')).swipe('left', 'slow', 0.6);
await waitForId('SwipeHideBalance');
await element(by.id('SwipeHideBalance')).tap();
await element(by.id('NavigationCloseButton')).tap();
await waitForId('WalletsList');
await sleep(1500); // ensure saveToDisk completes before app is killed
// restart app — hide state must persist; swipe-right now exposes "Show" (hideBalance persisted as true)
// restart app — hide state must persist; swipe-left now exposes "Show" (hideBalance persisted as true)
await device.launchApp({ newInstance: true });
await waitForId('WalletsList');
await element(by.id('cr34t3d')).longPress();
await waitForId('NavigationCloseButton');
await expect(element(by.id('cr34t3d'))).toBeVisible();
await element(by.text('cr34t3d')).swipe('right', 'slow', 0.7);
try {
await waitForId('SwipeShowBalance', 45000);
} catch (_waitErr) {
// Retry once: recycled list rows and gesture-handler timing can miss the first reveal on CI.
await element(by.text('cr34t3d')).swipe('right', 'slow', 0.8);
await waitForId('SwipeShowBalance', 45000);
}
await element(by.id('cr34t3d')).swipe('left', 'slow', 0.6);
await waitForId('SwipeShowBalance');
// restore visible state so subsequent tests are clean
await element(by.id('SwipeShowBalance')).tap();

View File

@ -58,8 +58,20 @@ export async function waitForText(text, timeout = 33000) {
await waitFor(element(by.text(text)))
.toBeVisible()
.withTimeout(timeout / 2);
return true;
} catch (err) {
rethrowWithCallsite(err, callsite);
// 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);
}
}
}
@ -216,15 +228,44 @@ 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');
await waitFor(element(by.id('PleasebackupOk')))
.toBeVisible()
.whileElement(by.id('PleaseBackupScrollView'))
.scroll(500, 'down'); // in case emu screen is small and it doesnt fit
// 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 element(by.id('PleasebackupOk')).tap();
await scrollUpOnHomeScreen();
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 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);
@ -297,6 +338,46 @@ 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.
@ -373,8 +454,14 @@ 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;
@ -393,6 +480,25 @@ 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,4 +43,19 @@ 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

@ -0,0 +1,51 @@
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

@ -208,6 +208,17 @@ 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 () {