Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31eca3cc2d |
2
Gemfile
2
Gemfile
@ -7,7 +7,7 @@ gem "fastlane", "~> 2.234.0"
|
|||||||
gem 'cocoapods', '>= 1.13', '!= 1.15.0', '!= 1.15.1'
|
gem 'cocoapods', '>= 1.13', '!= 1.15.0', '!= 1.15.1'
|
||||||
gem 'activesupport', '>= 6.1.7.5', '!= 7.1.0'
|
gem 'activesupport', '>= 6.1.7.5', '!= 7.1.0'
|
||||||
gem 'xcodeproj', '< 1.26.0'
|
gem 'xcodeproj', '< 1.26.0'
|
||||||
gem 'concurrent-ruby', '< 1.3.8'
|
gem 'concurrent-ruby', '< 1.3.4'
|
||||||
|
|
||||||
# Ruby 3.4.0 removed these from the standard library
|
# Ruby 3.4.0 removed these from the standard library
|
||||||
gem 'bigdecimal'
|
gem 'bigdecimal'
|
||||||
|
|||||||
@ -87,7 +87,7 @@ GEM
|
|||||||
colored2 (3.1.2)
|
colored2 (3.1.2)
|
||||||
commander (4.6.0)
|
commander (4.6.0)
|
||||||
highline (~> 2.0.0)
|
highline (~> 2.0.0)
|
||||||
concurrent-ruby (1.3.7)
|
concurrent-ruby (1.3.3)
|
||||||
connection_pool (3.0.2)
|
connection_pool (3.0.2)
|
||||||
csv (3.3.5)
|
csv (3.3.5)
|
||||||
declarative (0.0.20)
|
declarative (0.0.20)
|
||||||
@ -337,7 +337,7 @@ DEPENDENCIES
|
|||||||
benchmark
|
benchmark
|
||||||
bigdecimal
|
bigdecimal
|
||||||
cocoapods (>= 1.13, != 1.15.1, != 1.15.0)
|
cocoapods (>= 1.13, != 1.15.1, != 1.15.0)
|
||||||
concurrent-ruby (< 1.3.8)
|
concurrent-ruby (< 1.3.4)
|
||||||
fastlane (~> 2.234.0)
|
fastlane (~> 2.234.0)
|
||||||
fastlane-plugin-browserstack
|
fastlane-plugin-browserstack
|
||||||
fastlane-plugin-bugsnag
|
fastlane-plugin-bugsnag
|
||||||
@ -377,7 +377,7 @@ CHECKSUMS
|
|||||||
colored (1.2) sha256=9d82b47ac589ce7f6cab64b1f194a2009e9fd00c326a5357321f44afab2c1d2c
|
colored (1.2) sha256=9d82b47ac589ce7f6cab64b1f194a2009e9fd00c326a5357321f44afab2c1d2c
|
||||||
colored2 (3.1.2) sha256=b13c2bd7eeae2cf7356a62501d398e72fde78780bd26aec6a979578293c28b4a
|
colored2 (3.1.2) sha256=b13c2bd7eeae2cf7356a62501d398e72fde78780bd26aec6a979578293c28b4a
|
||||||
commander (4.6.0) sha256=7d1ddc3fccae60cc906b4131b916107e2ef0108858f485fdda30610c0f2913d9
|
commander (4.6.0) sha256=7d1ddc3fccae60cc906b4131b916107e2ef0108858f485fdda30610c0f2913d9
|
||||||
concurrent-ruby (1.3.7) sha256=4412caec3a5ea2e5fdc52076724c071a81f2c0593d83b2ac8cbb8ca63b3151b0
|
concurrent-ruby (1.3.3) sha256=4f9cd28965c4dcf83ffd3ea7304f9323277be8525819cb18a3b61edcb56a7c6a
|
||||||
connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a
|
connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a
|
||||||
csv (3.3.5) sha256=6e5134ac3383ef728b7f02725d9872934f523cb40b961479f69cf3afa6c8e73f
|
csv (3.3.5) sha256=6e5134ac3383ef728b7f02725d9872934f523cb40b961479f69cf3afa6c8e73f
|
||||||
declarative (0.0.20) sha256=8021dd6cb17ab2b61233c56903d3f5a259c5cf43c80ff332d447d395b17d9ff9
|
declarative (0.0.20) sha256=8021dd6cb17ab2b61233c56903d3f5a259c5cf43c80ff332d447d395b17d9ff9
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { cbc } from '@noble/ciphers/aes';
|
import { gcm, cbc } from '@noble/ciphers/aes';
|
||||||
import { md5 } from '@noble/hashes/legacy';
|
import { md5 } from '@noble/hashes/legacy';
|
||||||
|
import { scryptAsync } from '@noble/hashes/scrypt';
|
||||||
import { randomBytes } from '@noble/hashes/utils';
|
import { randomBytes } from '@noble/hashes/utils';
|
||||||
|
|
||||||
import { areUint8ArraysEqual, base64ToUint8Array, concatUint8Arrays, stringToUint8Array, uint8ArrayToBase64 } from './uint8array-extras';
|
import { areUint8ArraysEqual, base64ToUint8Array, concatUint8Arrays, stringToUint8Array, uint8ArrayToBase64 } from './uint8array-extras';
|
||||||
@ -36,39 +37,65 @@ export function evpBytesToKeyMd5(password: Uint8Array, salt: Uint8Array, byteLen
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// "Salted__" — OpenSSL envelope magic. Hardcoded as bytes so the wire
|
// =============================================================================
|
||||||
// format cannot drift through any encoder.
|
// v2: scrypt + AES-256-GCM. Always emitted by `encrypt`.
|
||||||
const SALT_MAGIC = new Uint8Array([0x53, 0x61, 0x6c, 0x74, 0x65, 0x64, 0x5f, 0x5f]);
|
// -----------------------------------------------------------------------------
|
||||||
const SALT_LEN = 8;
|
// Wire format: "v2:" + base64( salt(16) || nonce(12) || gcm_ciphertext_and_tag )
|
||||||
const KEY_LEN = 32;
|
// KDF: scrypt N=2^15, r=8, p=1, dkLen=32. Memory-hard, GPU/ASIC-resistant.
|
||||||
const IV_LEN = 16;
|
// Cipher: AES-256-GCM. AEAD — auth-tag mismatch on wrong password → throw → false.
|
||||||
const BLOCK_LEN = 16;
|
// =============================================================================
|
||||||
|
const V2_PREFIX = 'v2:';
|
||||||
|
const V2_SALT_LEN = 16;
|
||||||
|
const V2_NONCE_LEN = 12;
|
||||||
|
const V2_KEY_LEN = 32;
|
||||||
|
const V2_TAG_LEN = 16;
|
||||||
|
const V2_SCRYPT_OPTS = { N: 2 ** 15, r: 8, p: 1, dkLen: V2_KEY_LEN, asyncTick: 10 } as const;
|
||||||
|
|
||||||
/**
|
async function deriveV2Key(password: string, salt: Uint8Array): Promise<Uint8Array> {
|
||||||
* AES-256-CBC encrypt with the OpenSSL "Salted__" envelope, EVP_BytesToKey-MD5
|
return scryptAsync(stringToUint8Array(password), salt, V2_SCRYPT_OPTS);
|
||||||
* key derivation and PKCS7 padding. Output is base64-encoded.
|
|
||||||
*
|
|
||||||
* Wire format is bit-identical to CryptoJS@4.x's default
|
|
||||||
* `AES.encrypt(data, password).toString()` — we kept the swap-the-library
|
|
||||||
* change a drop-in replacement so existing encrypted wallets on user
|
|
||||||
* devices remain readable, with no migration step.
|
|
||||||
*/
|
|
||||||
export function encrypt(data: string, password: string): string {
|
|
||||||
if (data.length < 10) throw new Error('data length cant be < 10');
|
|
||||||
const salt = randomBytes(SALT_LEN);
|
|
||||||
const kdf = evpBytesToKeyMd5(stringToUint8Array(password), salt, KEY_LEN + IV_LEN);
|
|
||||||
const key = kdf.subarray(0, KEY_LEN);
|
|
||||||
const iv = kdf.subarray(KEY_LEN);
|
|
||||||
const ciphertext = cbc(key, iv).encrypt(stringToUint8Array(data));
|
|
||||||
return uint8ArrayToBase64(concatUint8Arrays([SALT_MAGIC, salt, ciphertext]));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async function encryptV2(data: string, password: string): Promise<string> {
|
||||||
* Inverse of `encrypt`. Accepts the legacy CryptoJS wire format and returns
|
const salt = randomBytes(V2_SALT_LEN);
|
||||||
* the original UTF-8 plaintext. Any error (bad base64, missing magic, wrong
|
const nonce = randomBytes(V2_NONCE_LEN);
|
||||||
* password, bad padding) collapses to `false`.
|
const key = await deriveV2Key(password, salt);
|
||||||
*/
|
const ct = gcm(key, nonce).encrypt(stringToUint8Array(data));
|
||||||
export function decrypt(data: string, password: string): string | false {
|
return V2_PREFIX + uint8ArrayToBase64(concatUint8Arrays([salt, nonce, ct]));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decryptV2(data: string, password: string): Promise<string | false> {
|
||||||
|
try {
|
||||||
|
const envelope = base64ToUint8Array(data.slice(V2_PREFIX.length).replace(/\s+/g, ''));
|
||||||
|
if (envelope.length < V2_SALT_LEN + V2_NONCE_LEN + V2_TAG_LEN) return false;
|
||||||
|
const salt = envelope.subarray(0, V2_SALT_LEN);
|
||||||
|
const nonce = envelope.subarray(V2_SALT_LEN, V2_SALT_LEN + V2_NONCE_LEN);
|
||||||
|
const ct = envelope.subarray(V2_SALT_LEN + V2_NONCE_LEN);
|
||||||
|
const key = await deriveV2Key(password, salt);
|
||||||
|
const plain = gcm(key, nonce).decrypt(ct); // throws on auth-tag mismatch (wrong password / tampered)
|
||||||
|
// Deliberately no `length < 10` belt-and-suspenders here: GCM's auth tag
|
||||||
|
// is the wrong-password gate. Any failed decrypt has already thrown
|
||||||
|
// above; if we reach this line, `plain` is the genuine plaintext.
|
||||||
|
return new TextDecoder('utf-8', { fatal: true }).decode(plain);
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// v1: EVP_BytesToKey-MD5 + AES-256-CBC. Read-only — kept for legacy ciphertexts.
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Wire format: base64( "Salted__"(8) || salt(8) || aes_cbc_pkcs7_ciphertext )
|
||||||
|
// New encryptions never emit this format. Existing on-device wallets stay
|
||||||
|
// readable forever; `decryptData` lazily rewrites them as v2 on the first
|
||||||
|
// successful unlock via `loadFromDisk` (the only opt-in caller).
|
||||||
|
// =============================================================================
|
||||||
|
const V1_SALT_MAGIC = new Uint8Array([0x53, 0x61, 0x6c, 0x74, 0x65, 0x64, 0x5f, 0x5f]); // "Salted__"
|
||||||
|
const V1_SALT_LEN = 8;
|
||||||
|
const V1_KEY_LEN = 32;
|
||||||
|
const V1_IV_LEN = 16;
|
||||||
|
const V1_BLOCK_LEN = 16;
|
||||||
|
|
||||||
|
function decryptV1(data: string, password: string): string | false {
|
||||||
try {
|
try {
|
||||||
// crypto-js's base64 decoder ignored whitespace. Some old encrypted-backup
|
// crypto-js's base64 decoder ignored whitespace. Some old encrypted-backup
|
||||||
// export/import flows (manual file paste, clipboard transit, email-based
|
// export/import flows (manual file paste, clipboard transit, email-based
|
||||||
@ -76,23 +103,49 @@ export function decrypt(data: string, password: string): string | false {
|
|||||||
// before strict base64 decode so legacy backups still open. `\s` does not
|
// before strict base64 decode so legacy backups still open. `\s` does not
|
||||||
// include `=`, so base64 padding survives.
|
// include `=`, so base64 padding survives.
|
||||||
const envelope = base64ToUint8Array(data.replace(/\s+/g, ''));
|
const envelope = base64ToUint8Array(data.replace(/\s+/g, ''));
|
||||||
if (envelope.length < SALT_MAGIC.length + SALT_LEN + BLOCK_LEN) return false;
|
if (envelope.length < V1_SALT_MAGIC.length + V1_SALT_LEN + V1_BLOCK_LEN) return false;
|
||||||
if (!areUint8ArraysEqual(envelope.subarray(0, SALT_MAGIC.length), SALT_MAGIC)) return false;
|
if (!areUint8ArraysEqual(envelope.subarray(0, V1_SALT_MAGIC.length), V1_SALT_MAGIC)) return false;
|
||||||
const salt = envelope.subarray(SALT_MAGIC.length, SALT_MAGIC.length + SALT_LEN);
|
const salt = envelope.subarray(V1_SALT_MAGIC.length, V1_SALT_MAGIC.length + V1_SALT_LEN);
|
||||||
const ciphertext = envelope.subarray(SALT_MAGIC.length + SALT_LEN);
|
const ciphertext = envelope.subarray(V1_SALT_MAGIC.length + V1_SALT_LEN);
|
||||||
const kdf = evpBytesToKeyMd5(stringToUint8Array(password), salt, KEY_LEN + IV_LEN);
|
const kdf = evpBytesToKeyMd5(stringToUint8Array(password), salt, V1_KEY_LEN + V1_IV_LEN);
|
||||||
const key = kdf.subarray(0, KEY_LEN);
|
const key = kdf.subarray(0, V1_KEY_LEN);
|
||||||
const iv = kdf.subarray(KEY_LEN);
|
const iv = kdf.subarray(V1_KEY_LEN);
|
||||||
const plain = cbc(key, iv).decrypt(ciphertext);
|
const plain = cbc(key, iv).decrypt(ciphertext);
|
||||||
// Strict UTF-8 decode — wrong-password decrypts that happen to survive
|
// Strict UTF-8 — wrong-password decrypts that happen to survive PKCS7 unpad
|
||||||
// PKCS7 unpadding overwhelmingly fail here (crypto-js's `enc.Utf8` was
|
// overwhelmingly fail here. crypto-js's `enc.Utf8` was strict; we match that
|
||||||
// strict too; we preserve that gate by using `fatal: true`).
|
// gate via `fatal: true`.
|
||||||
const str = new TextDecoder('utf-8', { fatal: true }).decode(plain);
|
const str = new TextDecoder('utf-8', { fatal: true }).decode(plain);
|
||||||
// Belt-and-suspenders: legitimate plaintext is always ≥ 10 chars
|
// Belt-and-suspenders: legitimate plaintext is always ≥ 10 chars (enforced
|
||||||
// (enforced by encrypt()), so anything shorter is rejected.
|
// by encrypt()), so anything shorter is treated as wrong-password garbage.
|
||||||
if (str.length < 10) return false;
|
if (str.length < 10) return false;
|
||||||
return str;
|
return str;
|
||||||
} catch (e) {
|
} catch (_) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Public API. Always async (scrypt KDF is non-trivial work; `scryptAsync` keeps
|
||||||
|
// the JS thread responsive by yielding every ~`asyncTick` ms).
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt `data` under `password` and return a `v2:`-prefixed base64 envelope
|
||||||
|
* (scrypt + AES-256-GCM). New writes always use this format; the legacy
|
||||||
|
* `Salted__` reader still handles ciphertexts produced by earlier app versions.
|
||||||
|
*/
|
||||||
|
export async function encrypt(data: string, password: string): Promise<string> {
|
||||||
|
if (data.length < 10) throw new Error('data length cant be < 10');
|
||||||
|
return encryptV2(data, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt either a `v2:` ciphertext (scrypt + GCM) or a legacy `Salted__`
|
||||||
|
* envelope (EVP_BytesToKey-MD5 + CBC). Any error (wrong password, tampered
|
||||||
|
* bytes, malformed input) collapses to `false`.
|
||||||
|
*/
|
||||||
|
export async function decrypt(data: string, password: string): Promise<string | false> {
|
||||||
|
if (typeof data !== 'string' || data.length === 0) return false;
|
||||||
|
if (data.startsWith(V2_PREFIX)) return decryptV2(data, password);
|
||||||
|
return decryptV1(data, password);
|
||||||
|
}
|
||||||
|
|||||||
@ -173,7 +173,7 @@ export class BlueApp {
|
|||||||
isPasswordInUse = async (password: string) => {
|
isPasswordInUse = async (password: string) => {
|
||||||
try {
|
try {
|
||||||
let data = await this.getItem('data');
|
let data = await this.getItem('data');
|
||||||
data = this.decryptData(data, password);
|
data = await this.decryptData(data, password);
|
||||||
return Boolean(data);
|
return Boolean(data);
|
||||||
} catch (_e) {
|
} catch (_e) {
|
||||||
return false;
|
return false;
|
||||||
@ -181,18 +181,37 @@ export class BlueApp {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Iterates through all values of `data` trying to
|
* Iterates through all values of `data` trying to decrypt each one, and
|
||||||
* decrypt each one, and returns first one successfully decrypted
|
* returns the first one successfully decrypted.
|
||||||
|
*
|
||||||
|
* Pass `{ upgrade: true }` to opt into the lazy v1 → v2 rewrite on success.
|
||||||
|
* The opt-in is deliberate: `isPasswordInUse` calls this method as a
|
||||||
|
* read-only probe during the create-fake-storage flow, and must NOT have
|
||||||
|
* the side-effect of mutating on-disk state (that would widen the same
|
||||||
|
* plausible-deniability fingerprint leak the lazy upgrade is closing).
|
||||||
|
* Only `loadFromDisk` (the real unlock path) opts in.
|
||||||
*/
|
*/
|
||||||
decryptData(data: string, password: string): boolean | string {
|
async decryptData(data: string, password: string, opts?: { upgrade?: boolean }): Promise<boolean | string> {
|
||||||
data = JSON.parse(data);
|
const buckets: string[] = JSON.parse(data);
|
||||||
let decrypted;
|
|
||||||
let num = 0;
|
let num = 0;
|
||||||
for (const value of data) {
|
for (const value of buckets) {
|
||||||
decrypted = encryption.decrypt(value, password);
|
const decrypted = await encryption.decrypt(value, password);
|
||||||
|
|
||||||
if (decrypted) {
|
if (decrypted) {
|
||||||
usedBucketNum = num;
|
usedBucketNum = num;
|
||||||
|
// Lazy v1 → v2 upgrade (only when explicitly requested): if this
|
||||||
|
// bucket is still in the legacy `Salted__` format, re-encrypt under
|
||||||
|
// the same password so the on-disk fingerprint converges over time.
|
||||||
|
// Decoy buckets the user never unlocks stay v1 — accepted
|
||||||
|
// plausible-deniability tradeoff, documented in release notes.
|
||||||
|
if (opts?.upgrade && !value.startsWith('v2:')) {
|
||||||
|
try {
|
||||||
|
buckets[num] = await encryption.encrypt(decrypted as string, password);
|
||||||
|
await this.setItem('data', JSON.stringify(buckets));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('lazy v2 upgrade failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
return decrypted;
|
return decrypted;
|
||||||
}
|
}
|
||||||
num++;
|
num++;
|
||||||
@ -220,7 +239,7 @@ export class BlueApp {
|
|||||||
let data = await this.getItem('data');
|
let data = await this.getItem('data');
|
||||||
// TODO: refactor ^^^ (should not save & load to fetch data)
|
// TODO: refactor ^^^ (should not save & load to fetch data)
|
||||||
|
|
||||||
const encrypted = encryption.encrypt(data, password);
|
const encrypted = await encryption.encrypt(data, password);
|
||||||
data = [];
|
data = [];
|
||||||
data.push(encrypted); // putting in array as we might have many buckets with storages
|
data.push(encrypted); // putting in array as we might have many buckets with storages
|
||||||
data = JSON.stringify(data);
|
data = JSON.stringify(data);
|
||||||
@ -247,7 +266,7 @@ export class BlueApp {
|
|||||||
|
|
||||||
let buckets = await this.getItem('data');
|
let buckets = await this.getItem('data');
|
||||||
buckets = JSON.parse(buckets);
|
buckets = JSON.parse(buckets);
|
||||||
buckets.push(encryption.encrypt(JSON.stringify(data), fakePassword));
|
buckets.push(await encryption.encrypt(JSON.stringify(data), fakePassword));
|
||||||
this.cachedPassword = fakePassword;
|
this.cachedPassword = fakePassword;
|
||||||
const bucketsString = JSON.stringify(buckets);
|
const bucketsString = JSON.stringify(buckets);
|
||||||
await this.setItem('data', bucketsString);
|
await this.setItem('data', bucketsString);
|
||||||
@ -363,7 +382,7 @@ export class BlueApp {
|
|||||||
}
|
}
|
||||||
let dataRaw = await this.getItemWithFallbackToRealm('data');
|
let dataRaw = await this.getItemWithFallbackToRealm('data');
|
||||||
if (password) {
|
if (password) {
|
||||||
dataRaw = this.decryptData(dataRaw, password);
|
dataRaw = await this.decryptData(dataRaw, password, { upgrade: true });
|
||||||
if (dataRaw) {
|
if (dataRaw) {
|
||||||
// password is good, cache it
|
// password is good, cache it
|
||||||
this.cachedPassword = password;
|
this.cachedPassword = password;
|
||||||
@ -674,7 +693,7 @@ export class BlueApp {
|
|||||||
} else {
|
} else {
|
||||||
// we dont have `usedBucketNum` for whatever reason, so lets try to decrypt each bucket after bucket
|
// we dont have `usedBucketNum` for whatever reason, so lets try to decrypt each bucket after bucket
|
||||||
// till we find the right one
|
// till we find the right one
|
||||||
decrypted = encryption.decrypt(bucket, this.cachedPassword);
|
decrypted = await encryption.decrypt(bucket, this.cachedPassword);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!decrypted) {
|
if (!decrypted) {
|
||||||
@ -683,7 +702,7 @@ export class BlueApp {
|
|||||||
} else {
|
} else {
|
||||||
// decrypted ok, this is our bucket
|
// decrypted ok, this is our bucket
|
||||||
// we serialize our object's data, encrypt it, and add it to buckets
|
// we serialize our object's data, encrypt it, and add it to buckets
|
||||||
newData.push(encryption.encrypt(JSON.stringify(data), this.cachedPassword));
|
newData.push(await encryption.encrypt(JSON.stringify(data), this.cachedPassword));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -321,21 +321,19 @@ export default class Lnurl {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static decipherAES(ciphertextBase64: string, preimageHex: string, ivBase64: string): string {
|
static decipherAES(ciphertextBase64: string, preimageHex: string, ivBase64: string): string {
|
||||||
// crypto-js's old implementation silently returned '' on malformed
|
// crypto-js's old path either returned '' or threw on malformed ciphertext,
|
||||||
// ciphertext (non-16-aligned bytes, bad PKCS7 padding) and threw on
|
// and `.toString(enc.Utf8)` strictly threw on invalid UTF-8. @noble/ciphers
|
||||||
// malformed UTF-8 plaintext. @noble/ciphers throws on the former. We
|
// throws on bad padding / non-16-aligned bytes. We wrap every throwing call
|
||||||
// catch every throw and return '' — the call site at
|
// — base64/hex decode, AES-CBC decrypt, and a `fatal: true` UTF-8 decode —
|
||||||
// screen/lnd/lnurlPaySuccess.tsx renders this directly without a
|
// and collapse all of them to '' so the user-facing call site at
|
||||||
// try/catch, so a misbehaving LNURL server should not crash the screen.
|
// screen/lnd/lnurlPaySuccess.tsx (no surrounding try/catch) cannot be tricked
|
||||||
// Note: unlike crypto-js's strict `enc.Utf8` decoder, `uint8ArrayToString`
|
// by a misbehaving LNURL server into rendering mojibake or crashing.
|
||||||
// is lenient on bad UTF-8 (mojibake instead of throw); this is strictly
|
|
||||||
// safer than the old behaviour for this user-facing path.
|
|
||||||
try {
|
try {
|
||||||
const key = hexToUint8Array(preimageHex);
|
const key = hexToUint8Array(preimageHex);
|
||||||
const iv = base64ToUint8Array(ivBase64);
|
const iv = base64ToUint8Array(ivBase64);
|
||||||
const ct = base64ToUint8Array(ciphertextBase64);
|
const ct = base64ToUint8Array(ciphertextBase64);
|
||||||
const pt = cbc(key, iv).decrypt(ct);
|
const pt = cbc(key, iv).decrypt(ct);
|
||||||
return uint8ArrayToString(pt);
|
return new TextDecoder('utf-8', { fatal: true }).decode(pt);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -52,14 +52,7 @@ const useFloatButtonAnimation = (initialHeight: number) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const getScaledButtonHeight = (fontScale: number): number => Math.round(LAYOUT.BUTTON_HEIGHT * fontScale);
|
const useFloatButtonLayout = (width: number, sizeClass: SizeClass) => {
|
||||||
|
|
||||||
/** 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 lastVerticalDecision = useRef(false);
|
||||||
|
|
||||||
const shouldUseVerticalLayout = useCallback(
|
const shouldUseVerticalLayout = useCallback(
|
||||||
@ -159,19 +152,15 @@ const useFloatButtonLayout = (width: number, sizeClass: SizeClass, fontScale: nu
|
|||||||
[width, sizeClass, shouldUseVerticalLayout],
|
[width, sizeClass, shouldUseVerticalLayout],
|
||||||
);
|
);
|
||||||
|
|
||||||
const calculateContainerHeight = useCallback(
|
const calculateContainerHeight = useCallback((childrenCount: number, isVerticalLayout: boolean) => {
|
||||||
(childrenCount: number, isVerticalLayout: boolean) => {
|
if (!isVerticalLayout) return { height: '8%', minHeight: LAYOUT.BUTTON_HEIGHT };
|
||||||
const buttonHeight = getScaledButtonHeight(fontScale);
|
|
||||||
if (!isVerticalLayout) return { height: '8%', minHeight: buttonHeight };
|
|
||||||
|
|
||||||
const totalButtonsHeight = childrenCount * buttonHeight;
|
const totalButtonsHeight = childrenCount * LAYOUT.BUTTON_HEIGHT;
|
||||||
const totalMarginsHeight = (childrenCount - 1) * LAYOUT.BUTTON_MARGIN;
|
const totalMarginsHeight = (childrenCount - 1) * LAYOUT.BUTTON_MARGIN;
|
||||||
const calculatedHeight = totalButtonsHeight + totalMarginsHeight;
|
const calculatedHeight = totalButtonsHeight + totalMarginsHeight;
|
||||||
|
|
||||||
return { height: calculatedHeight };
|
return { height: calculatedHeight };
|
||||||
},
|
}, []);
|
||||||
[fontScale],
|
|
||||||
);
|
|
||||||
|
|
||||||
const calculateButtonFontSize = useMemo(() => {
|
const calculateButtonFontSize = useMemo(() => {
|
||||||
const divisor = sizeClass === SizeClass.Large ? 22 : sizeClass === SizeClass.Regular ? 24 : 28;
|
const divisor = sizeClass === SizeClass.Large ? 22 : sizeClass === SizeClass.Regular ? 24 : 28;
|
||||||
@ -278,7 +267,6 @@ interface FButtonProps {
|
|||||||
isVertical?: boolean;
|
isVertical?: boolean;
|
||||||
borderRadius?: number;
|
borderRadius?: number;
|
||||||
fontSize?: number;
|
fontSize?: number;
|
||||||
buttonHeight?: number;
|
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
testID?: string;
|
testID?: string;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
@ -289,14 +277,13 @@ interface ButtonContentProps {
|
|||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
text: string;
|
text: string;
|
||||||
textStyle: StyleProp<TextStyle>;
|
textStyle: StyleProp<TextStyle>;
|
||||||
buttonHeight: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const getScaledIconSize = (fontSize: number): number => {
|
const getScaledIconSize = (fontSize: number): number => {
|
||||||
return Math.max(Math.round(fontSize * 1.2), 16);
|
return Math.max(Math.round(fontSize * 1.2), 16);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ButtonContent = ({ icon, text, textStyle, buttonHeight }: ButtonContentProps) => {
|
const ButtonContent = ({ icon, text, textStyle }: ButtonContentProps) => {
|
||||||
const computedStyle = StyleSheet.flatten(textStyle);
|
const computedStyle = StyleSheet.flatten(textStyle);
|
||||||
const fontSize = computedStyle.fontSize || LAYOUT.MAX_BUTTON_FONT_SIZE;
|
const fontSize = computedStyle.fontSize || LAYOUT.MAX_BUTTON_FONT_SIZE;
|
||||||
const iconSize = getScaledIconSize(Number(fontSize));
|
const iconSize = getScaledIconSize(Number(fontSize));
|
||||||
@ -320,14 +307,9 @@ const ButtonContent = ({ icon, text, textStyle, buttonHeight }: ButtonContentPro
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[buttonContentStaticStyles.contentContainer, { minHeight: buttonHeight }]}>
|
<View style={buttonContentStaticStyles.contentContainer}>
|
||||||
<View style={buttonStyles.iconContainer}>{scaledIcon}</View>
|
<View style={buttonStyles.iconContainer}>{scaledIcon}</View>
|
||||||
<Text
|
<Text numberOfLines={1} adjustsFontSizeToFit style={[textStyle, buttonStyles.centeredText, { lineHeight: fontSize * 1.2 }]}>
|
||||||
numberOfLines={1}
|
|
||||||
adjustsFontSizeToFit
|
|
||||||
minimumFontScale={0.8}
|
|
||||||
style={[textStyle, buttonStyles.centeredText, { lineHeight: fontSize * 1.2 }]}
|
|
||||||
>
|
|
||||||
{text}
|
{text}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@ -343,7 +325,6 @@ export const FButton = ({
|
|||||||
isVertical,
|
isVertical,
|
||||||
borderRadius = LAYOUT.PILL_BORDER_RADIUS,
|
borderRadius = LAYOUT.PILL_BORDER_RADIUS,
|
||||||
fontSize = LAYOUT.MAX_BUTTON_FONT_SIZE,
|
fontSize = LAYOUT.MAX_BUTTON_FONT_SIZE,
|
||||||
buttonHeight = LAYOUT.BUTTON_HEIGHT,
|
|
||||||
testID,
|
testID,
|
||||||
...props
|
...props
|
||||||
}: FButtonProps) => {
|
}: FButtonProps) => {
|
||||||
@ -366,8 +347,6 @@ export const FButton = ({
|
|||||||
return {
|
return {
|
||||||
root: {
|
root: {
|
||||||
...baseStyles,
|
...baseStyles,
|
||||||
height: buttonHeight,
|
|
||||||
minHeight: buttonHeight,
|
|
||||||
backgroundColor: colors.buttonBackgroundColor,
|
backgroundColor: colors.buttonBackgroundColor,
|
||||||
},
|
},
|
||||||
text: {
|
text: {
|
||||||
@ -381,7 +360,7 @@ export const FButton = ({
|
|||||||
marginBottom: buttonContentStaticStyles.marginBottom,
|
marginBottom: buttonContentStaticStyles.marginBottom,
|
||||||
textBase: buttonContentStaticStyles.textBase,
|
textBase: buttonContentStaticStyles.textBase,
|
||||||
};
|
};
|
||||||
}, [colors, fontSize, buttonHeight]);
|
}, [colors, fontSize]);
|
||||||
|
|
||||||
const style: Record<string, any> = {};
|
const style: Record<string, any> = {};
|
||||||
const additionalStyles = !last ? (isVertical ? customButtonStyles.marginBottom : customButtonStyles.marginRight) : {};
|
const additionalStyles = !last ? (isVertical ? customButtonStyles.marginBottom : customButtonStyles.marginRight) : {};
|
||||||
@ -418,7 +397,7 @@ export const FButton = ({
|
|||||||
style={[buttonStyles.root, customButtonStyles.root, style, { borderRadius }]}
|
style={[buttonStyles.root, customButtonStyles.root, style, { borderRadius }]}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ButtonContent icon={icon} text={text} textStyle={textStyle} buttonHeight={buttonHeight} />
|
<ButtonContent icon={icon} text={text} textStyle={textStyle} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
);
|
);
|
||||||
@ -426,9 +405,8 @@ export const FButton = ({
|
|||||||
|
|
||||||
export const FContainer = forwardRef<View, FContainerProps>((props, ref) => {
|
export const FContainer = forwardRef<View, FContainerProps>((props, ref) => {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { height, width, fontScale } = useWindowDimensions();
|
const { height, width } = useWindowDimensions();
|
||||||
const { sizeClass } = useSizeClass();
|
const { sizeClass } = useSizeClass();
|
||||||
const scaledButtonHeight = getScaledButtonHeight(fontScale);
|
|
||||||
|
|
||||||
const childrenCount = React.Children.toArray(props.children).filter(Boolean).length;
|
const childrenCount = React.Children.toArray(props.children).filter(Boolean).length;
|
||||||
|
|
||||||
@ -441,7 +419,6 @@ export const FContainer = forwardRef<View, FContainerProps>((props, ref) => {
|
|||||||
const { calculateButtonWidth, calculateVisualParameters, calculateContainerHeight, buttonFontSize } = useFloatButtonLayout(
|
const { calculateButtonWidth, calculateVisualParameters, calculateContainerHeight, buttonFontSize } = useFloatButtonLayout(
|
||||||
width,
|
width,
|
||||||
sizeClass,
|
sizeClass,
|
||||||
fontScale,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Compute initial geometry up-front so the slide-in animation starts at the final (computed) size,
|
// Compute initial geometry up-front so the slide-in animation starts at the final (computed) size,
|
||||||
@ -531,7 +508,7 @@ export const FContainer = forwardRef<View, FContainerProps>((props, ref) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
debouncedCalculateLayout();
|
debouncedCalculateLayout();
|
||||||
}, [debouncedCalculateLayout, width, height, childrenCount, sizeClass, fontScale]);
|
}, [debouncedCalculateLayout, width, height, childrenCount, sizeClass]);
|
||||||
|
|
||||||
const onLayout = (event: { nativeEvent: { layout: { width: number } } }) => {
|
const onLayout = (event: { nativeEvent: { layout: { width: number } } }) => {
|
||||||
const { width: currentLayoutWidth } = event.nativeEvent.layout;
|
const { width: currentLayoutWidth } = event.nativeEvent.layout;
|
||||||
@ -568,7 +545,6 @@ export const FContainer = forwardRef<View, FContainerProps>((props, ref) => {
|
|||||||
isVertical,
|
isVertical,
|
||||||
borderRadius: buttonBorderRadius,
|
borderRadius: buttonBorderRadius,
|
||||||
fontSize: buttonFontSize,
|
fontSize: buttonFontSize,
|
||||||
buttonHeight: scaledButtonHeight,
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -585,10 +561,10 @@ export const FContainer = forwardRef<View, FContainerProps>((props, ref) => {
|
|||||||
props.inline ? containerStyles.rootInline : containerStyles.rootAbsolute,
|
props.inline ? containerStyles.rootInline : containerStyles.rootAbsolute,
|
||||||
bottomInsets,
|
bottomInsets,
|
||||||
effectiveNewWidth ? (isVertical ? containerStyles.rootPostVertical : containerStyles.rootPost) : containerStyles.rootPre,
|
effectiveNewWidth ? (isVertical ? containerStyles.rootPostVertical : containerStyles.rootPost) : containerStyles.rootPre,
|
||||||
isVertical ? containerHeight : { minHeight: scaledButtonHeight },
|
isVertical ? containerHeight : null,
|
||||||
{ transform: [{ translateY: slideAnimation }] },
|
{ transform: [{ translateY: slideAnimation }] },
|
||||||
],
|
],
|
||||||
[props.inline, bottomInsets, effectiveNewWidth, isVertical, containerHeight, slideAnimation, scaledButtonHeight],
|
[props.inline, bottomInsets, effectiveNewWidth, isVertical, containerHeight, slideAnimation],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -1,13 +1,10 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { Pressable, StyleProp, StyleSheet, Switch, SwitchProps, Text, TextStyle, useWindowDimensions, View, ViewStyle } from 'react-native';
|
import { Pressable, StyleProp, StyleSheet, Switch, SwitchProps, Text, TextStyle, View, ViewStyle } from 'react-native';
|
||||||
import { useLocale } from '@react-navigation/native';
|
import { useLocale } from '@react-navigation/native';
|
||||||
|
|
||||||
import Icon from './Icon';
|
import Icon from './Icon';
|
||||||
import { useTheme } from './themes';
|
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 {
|
interface ListItemProps {
|
||||||
leftAvatar?: React.JSX.Element;
|
leftAvatar?: React.JSX.Element;
|
||||||
containerStyle?: StyleProp<ViewStyle>;
|
containerStyle?: StyleProp<ViewStyle>;
|
||||||
@ -58,20 +55,12 @@ const ListItem: React.FC<ListItemProps> = React.memo(
|
|||||||
}: ListItemProps) => {
|
}: ListItemProps) => {
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
const { direction } = useLocale();
|
const { direction } = useLocale();
|
||||||
const { fontScale } = useWindowDimensions();
|
|
||||||
const isRtl = direction === 'rtl';
|
const isRtl = direction === 'rtl';
|
||||||
const contentRowStyle = useMemo(
|
|
||||||
() => ({
|
|
||||||
paddingVertical: Math.round(12 * fontScale),
|
|
||||||
}),
|
|
||||||
[fontScale],
|
|
||||||
);
|
|
||||||
const stylesHook = StyleSheet.create({
|
const stylesHook = StyleSheet.create({
|
||||||
title: {
|
title: {
|
||||||
color: disabled ? colors.buttonDisabledTextColor : colors.foregroundColor,
|
color: disabled ? colors.buttonDisabledTextColor : colors.foregroundColor,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
lineHeight: Math.round(22 * fontScale),
|
|
||||||
writingDirection: direction,
|
writingDirection: direction,
|
||||||
},
|
},
|
||||||
rightMemoText: {
|
rightMemoText: {
|
||||||
@ -83,7 +72,7 @@ const ListItem: React.FC<ListItemProps> = React.memo(
|
|||||||
color: colors.alternativeTextColor,
|
color: colors.alternativeTextColor,
|
||||||
fontWeight: '400',
|
fontWeight: '400',
|
||||||
paddingVertical: switchProps ? 8 : 0,
|
paddingVertical: switchProps ? 8 : 0,
|
||||||
lineHeight: Math.round(20 * fontScale),
|
lineHeight: 20,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
},
|
},
|
||||||
@ -104,7 +93,7 @@ const ListItem: React.FC<ListItemProps> = React.memo(
|
|||||||
const enableFeedback = !noFeedback && !!onPress && !disabled;
|
const enableFeedback = !noFeedback && !!onPress && !disabled;
|
||||||
|
|
||||||
const renderContent = () => (
|
const renderContent = () => (
|
||||||
<View style={[styles.contentRow, contentRowStyle]}>
|
<View style={styles.contentRow}>
|
||||||
{leftAvatar && (
|
{leftAvatar && (
|
||||||
<View style={styles.leftAvatarContainer}>
|
<View style={styles.leftAvatarContainer}>
|
||||||
{leftAvatar}
|
{leftAvatar}
|
||||||
@ -125,14 +114,7 @@ const ListItem: React.FC<ListItemProps> = React.memo(
|
|||||||
{rightTitle || rightSubtitle ? (
|
{rightTitle || rightSubtitle ? (
|
||||||
<View style={styles.rightColumn}>
|
<View style={styles.rightColumn}>
|
||||||
{rightTitle ? (
|
{rightTitle ? (
|
||||||
<Text
|
<Text style={rightTitleStyle} numberOfLines={1} accessibilityRole="text" selectable={rightTitleSelectable}>
|
||||||
style={rightTitleStyle}
|
|
||||||
numberOfLines={1}
|
|
||||||
adjustsFontSizeToFit
|
|
||||||
minimumFontScale={0.75}
|
|
||||||
accessibilityRole="text"
|
|
||||||
selectable={rightTitleSelectable}
|
|
||||||
>
|
|
||||||
{rightTitle}
|
{rightTitle}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
@ -210,20 +192,16 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
content: {
|
content: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
flexShrink: 1,
|
|
||||||
minWidth: 0,
|
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
leftAvatarContainer: {
|
leftAvatarContainer: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
alignSelf: 'center',
|
|
||||||
},
|
},
|
||||||
rightColumn: {
|
rightColumn: {
|
||||||
marginStart: 8,
|
marginStart: 8,
|
||||||
flexShrink: 0,
|
minWidth: 0,
|
||||||
alignItems: 'flex-end',
|
alignItems: 'flex-end',
|
||||||
alignSelf: 'center',
|
|
||||||
},
|
},
|
||||||
rightMemoWrapper: {
|
rightMemoWrapper: {
|
||||||
flexShrink: 1,
|
flexShrink: 1,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import React, { useMemo, useCallback } from 'react';
|
import React, { useMemo, useCallback } from 'react';
|
||||||
import { TouchableOpacity, Text, StyleSheet, View, useWindowDimensions } from 'react-native';
|
import { TouchableOpacity, Text, StyleSheet, View } from 'react-native';
|
||||||
import { useStorage } from '../hooks/context/useStorage';
|
import { useStorage } from '../hooks/context/useStorage';
|
||||||
import loc, { formatBalanceWithoutSuffix } from '../loc';
|
import loc, { formatBalanceWithoutSuffix } from '../loc';
|
||||||
import { BitcoinUnit } from '../models/bitcoinUnits';
|
import { BitcoinUnit } from '../models/bitcoinUnits';
|
||||||
@ -22,7 +22,6 @@ const TotalWalletsBalance: React.FC = React.memo(() => {
|
|||||||
setTotalBalancePreferredUnitStorage,
|
setTotalBalancePreferredUnitStorage,
|
||||||
} = useSettings();
|
} = useSettings();
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
const { fontScale } = useWindowDimensions();
|
|
||||||
|
|
||||||
const totalBalanceFormatted = useMemo(() => {
|
const totalBalanceFormatted = useMemo(() => {
|
||||||
const totalBalance = wallets.reduce((prev, curr) => {
|
const totalBalance = wallets.reduce((prev, curr) => {
|
||||||
@ -32,22 +31,6 @@ const TotalWalletsBalance: React.FC = React.memo(() => {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [wallets, totalBalancePreferredUnit, preferredFiatCurrency]);
|
}, [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(
|
const toolTipActions = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
@ -109,20 +92,13 @@ const TotalWalletsBalance: React.FC = React.memo(() => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ToolTipMenu actions={toolTipActions} onPressMenuItem={onPressMenuItem} shouldOpenOnLongPress style={styles.menuContainer}>
|
<ToolTipMenu actions={toolTipActions} onPressMenuItem={onPressMenuItem} shouldOpenOnLongPress style={styles.menuContainer}>
|
||||||
<View style={[styles.container, scaledStyles.container]}>
|
<View style={styles.container}>
|
||||||
<Text style={[styles.label, scaledStyles.label]} numberOfLines={1} adjustsFontSizeToFit minimumFontScale={0.8}>
|
<Text style={styles.label}>{loc.wallets.total_balance}</Text>
|
||||||
{loc.wallets.total_balance}
|
<TouchableOpacity onPress={handleBalanceOnPress}>
|
||||||
</Text>
|
<Text style={[styles.balance, { color: colors.foregroundColor }]}>
|
||||||
<TouchableOpacity onPress={handleBalanceOnPress} style={styles.balanceTouchable}>
|
{totalBalanceFormatted}{' '}
|
||||||
<Text
|
|
||||||
style={[styles.balance, scaledStyles.balance, { color: colors.foregroundColor }]}
|
|
||||||
numberOfLines={1}
|
|
||||||
adjustsFontSizeToFit
|
|
||||||
minimumFontScale={0.55}
|
|
||||||
>
|
|
||||||
{totalBalanceFormatted}
|
|
||||||
{totalBalancePreferredUnit !== BitcoinUnit.LOCAL_CURRENCY && (
|
{totalBalancePreferredUnit !== BitcoinUnit.LOCAL_CURRENCY && (
|
||||||
<Text style={[styles.currency, { color: colors.foregroundColor }]}>{` ${totalBalancePreferredUnit}`}</Text>
|
<Text style={[styles.currency, { color: colors.foregroundColor }]}>{totalBalancePreferredUnit}</Text>
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@ -140,11 +116,6 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'flex-start',
|
alignItems: 'flex-start',
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
paddingVertical: 8,
|
paddingVertical: 8,
|
||||||
width: '100%',
|
|
||||||
},
|
|
||||||
balanceTouchable: {
|
|
||||||
alignSelf: 'stretch',
|
|
||||||
width: '100%',
|
|
||||||
},
|
},
|
||||||
label: {
|
label: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
@ -154,7 +125,6 @@ const styles = StyleSheet.create({
|
|||||||
balance: {
|
balance: {
|
||||||
fontSize: 32,
|
fontSize: 32,
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
lineHeight: 38,
|
|
||||||
},
|
},
|
||||||
currency: {
|
currency: {
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import React, { memo, useCallback, useMemo, useRef } from 'react';
|
import React, { memo, useCallback, useMemo, useRef } from 'react';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import Clipboard from '@react-native-clipboard/clipboard';
|
import Clipboard from '@react-native-clipboard/clipboard';
|
||||||
import { Animated, Easing, Linking, Pressable, Text, TextStyle, ViewStyle, StyleSheet, View, useWindowDimensions } from 'react-native';
|
import { Animated, Easing, Linking, Pressable, Text, TextStyle, ViewStyle, StyleSheet, View } from 'react-native';
|
||||||
import Lnurl from '../class/lnurl';
|
import Lnurl from '../class/lnurl';
|
||||||
import { LightningArkWallet } from '../class/wallets/lightning-ark-wallet';
|
import { LightningArkWallet } from '../class/wallets/lightning-ark-wallet';
|
||||||
import { LightningTransaction, Transaction } from '../class/wallets/types';
|
import { LightningTransaction, Transaction } from '../class/wallets/types';
|
||||||
@ -29,6 +29,9 @@ import { uint8ArrayToHex } from '../blue_modules/uint8array-extras';
|
|||||||
import ListItem from './ListItem';
|
import ListItem from './ListItem';
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
|
dateLine: {
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
fullWidthButton: {
|
fullWidthButton: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
alignSelf: 'stretch',
|
alignSelf: 'stretch',
|
||||||
@ -130,7 +133,6 @@ const TransactionListItemComponent: React.FC<TransactionListItemProps> = ({
|
|||||||
const { txMetadata, counterpartyMetadata, wallets } = useStorage();
|
const { txMetadata, counterpartyMetadata, wallets } = useStorage();
|
||||||
const { language, selectedBlockExplorer } = useSettings();
|
const { language, selectedBlockExplorer } = useSettings();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { fontScale } = useWindowDimensions();
|
|
||||||
const containerStyle = useMemo(
|
const containerStyle = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
backgroundColor: colors.background,
|
backgroundColor: colors.background,
|
||||||
@ -246,7 +248,6 @@ const TransactionListItemComponent: React.FC<TransactionListItemProps> = ({
|
|||||||
color,
|
color,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: '600' as TextStyle['fontWeight'],
|
fontWeight: '600' as TextStyle['fontWeight'],
|
||||||
lineHeight: Math.round(20 * fontScale),
|
|
||||||
textAlign: 'right',
|
textAlign: 'right',
|
||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
paddingLeft: insets.left,
|
paddingLeft: insets.left,
|
||||||
@ -261,7 +262,6 @@ const TransactionListItemComponent: React.FC<TransactionListItemProps> = ({
|
|||||||
item.ispaid,
|
item.ispaid,
|
||||||
insets.right,
|
insets.right,
|
||||||
insets.left,
|
insets.left,
|
||||||
fontScale,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const determineTransactionTypeAndAvatar = () => {
|
const determineTransactionTypeAndAvatar = () => {
|
||||||
@ -549,7 +549,7 @@ const TransactionListItemComponent: React.FC<TransactionListItemProps> = ({
|
|||||||
<ListItem
|
<ListItem
|
||||||
leftAvatar={avatar}
|
leftAvatar={avatar}
|
||||||
title={listTitle}
|
title={listTitle}
|
||||||
subtitle={dateLine}
|
subtitle={<Text style={styles.dateLine}>{dateLine}</Text>}
|
||||||
chevron={false}
|
chevron={false}
|
||||||
rightTitle={rowTitle}
|
rightTitle={rowTitle}
|
||||||
rightTitleStyle={rowTitleStyle}
|
rightTitleStyle={rowTitleStyle}
|
||||||
|
|||||||
@ -254,7 +254,6 @@ const styles = StyleSheet.create({
|
|||||||
position: 'relative',
|
position: 'relative',
|
||||||
},
|
},
|
||||||
contentContainer: {
|
contentContainer: {
|
||||||
flex: 1,
|
|
||||||
paddingTop: WALLET_LABEL_TOP_GAP,
|
paddingTop: WALLET_LABEL_TOP_GAP,
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
paddingBottom: HERO_BOTTOM_PADDING,
|
paddingBottom: HERO_BOTTOM_PADDING,
|
||||||
|
|||||||
@ -30,7 +30,6 @@ import WalletGradient from '../class/wallet-gradient';
|
|||||||
import { useSizeClass, SizeClass } from '../blue_modules/sizeClass';
|
import { useSizeClass, SizeClass } from '../blue_modules/sizeClass';
|
||||||
import loc, { formatBalance, transactionTimeToReadable } from '../loc';
|
import loc, { formatBalance, transactionTimeToReadable } from '../loc';
|
||||||
import { BlurredBalanceView } from './BlurredBalanceView';
|
import { BlurredBalanceView } from './BlurredBalanceView';
|
||||||
import { withAlpha } from './color';
|
|
||||||
import { useTheme } from './themes';
|
import { useTheme } from './themes';
|
||||||
import { Transaction, TWallet } from '../class/wallets/types';
|
import { Transaction, TWallet } from '../class/wallets/types';
|
||||||
import { BlueSpacing10 } from './BlueSpacing';
|
import { BlueSpacing10 } from './BlueSpacing';
|
||||||
@ -38,30 +37,6 @@ import { useLocale } from '@react-navigation/native';
|
|||||||
|
|
||||||
export const WALLET_CAROUSEL_HEADER_WIDTH = 16;
|
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);
|
export const getWalletCarouselItemWidth = (screenWidth: number) => Math.round(screenWidth * 0.82 > 375 ? 375 : screenWidth * 0.82);
|
||||||
|
|
||||||
interface NewWalletPanelProps {
|
interface NewWalletPanelProps {
|
||||||
@ -185,28 +160,23 @@ const iStyles = StyleSheet.create({
|
|||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
minHeight: 164,
|
minHeight: 164,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
justifyContent: 'flex-end',
|
|
||||||
},
|
},
|
||||||
gradCompact: {
|
gradCompact: {
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
minHeight: 132,
|
minHeight: 132,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
justifyContent: 'flex-end',
|
|
||||||
},
|
},
|
||||||
gradContent: {
|
gradContent: {
|
||||||
padding: 15,
|
padding: 15,
|
||||||
width: '100%',
|
|
||||||
},
|
},
|
||||||
gradContentCompact: {
|
gradContentCompact: {
|
||||||
padding: 12,
|
padding: 12,
|
||||||
},
|
},
|
||||||
balanceContainer: {
|
balanceContainer: {
|
||||||
minHeight: 40,
|
height: 40,
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
},
|
||||||
balanceContainerCompact: {
|
balanceContainerCompact: {
|
||||||
minHeight: 32,
|
height: 32,
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
},
|
||||||
image: {
|
image: {
|
||||||
width: 99,
|
width: 99,
|
||||||
@ -219,6 +189,9 @@ const iStyles = StyleSheet.create({
|
|||||||
width: 78,
|
width: 78,
|
||||||
height: 74,
|
height: 74,
|
||||||
},
|
},
|
||||||
|
br: {
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
},
|
||||||
label: {
|
label: {
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
fontSize: 19,
|
fontSize: 19,
|
||||||
@ -233,6 +206,7 @@ const iStyles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
balanceCompact: {
|
balanceCompact: {
|
||||||
fontSize: 28,
|
fontSize: 28,
|
||||||
|
lineHeight: 34,
|
||||||
},
|
},
|
||||||
latestTx: {
|
latestTx: {
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
@ -308,32 +282,11 @@ export const WalletCarouselItem: React.FC<WalletCarouselItemProps> = React.memo(
|
|||||||
const balanceOpacity = useSharedValue(1);
|
const balanceOpacity = useSharedValue(1);
|
||||||
const balanceTranslateY = useSharedValue(0);
|
const balanceTranslateY = useSharedValue(0);
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
const { width, fontScale } = useWindowDimensions();
|
const { width } = useWindowDimensions();
|
||||||
const itemWidth = getWalletCarouselItemWidth(width);
|
const itemWidth = getWalletCarouselItemWidth(width);
|
||||||
const { sizeClass } = useSizeClass();
|
const { sizeClass } = useSizeClass();
|
||||||
const isCompact = sizeVariant === 'compact';
|
const isCompact = sizeVariant === 'compact';
|
||||||
const { direction } = useLocale();
|
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 previousBalance = useRef<string | undefined>(undefined);
|
||||||
const balance = !hideBalance && formatBalance(Number(item.getBalance()), item.getPreferredBalanceUnit(), true);
|
const balance = !hideBalance && formatBalance(Number(item.getBalance()), item.getPreferredBalanceUnit(), true);
|
||||||
const safeBalance = balance || undefined;
|
const safeBalance = balance || undefined;
|
||||||
@ -478,23 +431,23 @@ export const WalletCarouselItem: React.FC<WalletCarouselItemProps> = React.memo(
|
|||||||
{ backgroundColor: colors.background, shadowColor: colors.shadowColor },
|
{ backgroundColor: colors.background, shadowColor: colors.shadowColor },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<LinearGradient
|
<LinearGradient colors={WalletGradient.gradientsFor(item.type)} style={[iStyles.grad, isCompact && iStyles.gradCompact]}>
|
||||||
colors={WalletGradient.gradientsFor(item.type)}
|
|
||||||
style={[iStyles.grad, isCompact && iStyles.gradCompact, scaledCardStyles.grad]}
|
|
||||||
>
|
|
||||||
<ImageBackground source={image} style={[iStyles.image, isCompact && iStyles.imageCompact]} />
|
<ImageBackground source={image} style={[iStyles.image, isCompact && iStyles.imageCompact]} />
|
||||||
<View style={[iStyles.gradContent, isCompact && iStyles.gradContentCompact, !isCompact && scaledCardStyles.gradContent]}>
|
<View style={[iStyles.gradContent, isCompact && iStyles.gradContentCompact]}>
|
||||||
|
<Text style={iStyles.br} />
|
||||||
{!isPlaceHolder && (
|
{!isPlaceHolder && (
|
||||||
<>
|
<>
|
||||||
<Text
|
<Text
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
style={[iStyles.label, isCompact && iStyles.labelCompact, scaledCardStyles.label, cardTextStyle]}
|
style={[
|
||||||
|
iStyles.label,
|
||||||
|
isCompact && iStyles.labelCompact,
|
||||||
|
{ color: colors.inverseForegroundColor, writingDirection: direction },
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
{renderHighlightedText ? renderHighlightedText(walletLabel, searchQuery || '') : walletLabel}
|
{renderHighlightedText ? renderHighlightedText(walletLabel, searchQuery || '') : walletLabel}
|
||||||
</Text>
|
</Text>
|
||||||
<View
|
<View style={[iStyles.balanceContainer, isCompact && iStyles.balanceContainerCompact]}>
|
||||||
style={[iStyles.balanceContainer, isCompact && iStyles.balanceContainerCompact, scaledCardStyles.balanceContainer]}
|
|
||||||
>
|
|
||||||
{hideBalance ? (
|
{hideBalance ? (
|
||||||
<>
|
<>
|
||||||
<BlueSpacing10 />
|
<BlueSpacing10 />
|
||||||
@ -504,13 +457,11 @@ export const WalletCarouselItem: React.FC<WalletCarouselItemProps> = React.memo(
|
|||||||
<Animated.Text
|
<Animated.Text
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
adjustsFontSizeToFit
|
adjustsFontSizeToFit
|
||||||
minimumFontScale={0.55}
|
|
||||||
key={`${balance}`} // force component recreation on balance change. To fix right-to-left languages, like Farsi
|
key={`${balance}`} // force component recreation on balance change. To fix right-to-left languages, like Farsi
|
||||||
style={[
|
style={[
|
||||||
iStyles.balance,
|
iStyles.balance,
|
||||||
isCompact && iStyles.balanceCompact,
|
isCompact && iStyles.balanceCompact,
|
||||||
isCompact ? scaledCardStyles.balanceCompact : scaledCardStyles.balance,
|
{ color: colors.inverseForegroundColor, writingDirection: direction },
|
||||||
cardTextStyle,
|
|
||||||
animatedBalanceStyle,
|
animatedBalanceStyle,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
@ -518,20 +469,24 @@ export const WalletCarouselItem: React.FC<WalletCarouselItemProps> = React.memo(
|
|||||||
</Animated.Text>
|
</Animated.Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
<View style={scaledCardStyles.textSpacer} />
|
<Text style={iStyles.br} />
|
||||||
<Text
|
<Text
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
adjustsFontSizeToFit
|
style={[
|
||||||
minimumFontScale={0.8}
|
iStyles.latestTx,
|
||||||
style={[iStyles.latestTx, isCompact && iStyles.latestTxCompact, scaledCardStyles.latestTx, cardTextStyle]}
|
isCompact && iStyles.latestTxCompact,
|
||||||
|
{ color: colors.inverseForegroundColor, writingDirection: direction },
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
{loc.wallets.list_latest_transaction}
|
{loc.wallets.list_latest_transaction}
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
adjustsFontSizeToFit
|
style={[
|
||||||
minimumFontScale={0.8}
|
iStyles.latestTxTime,
|
||||||
style={[iStyles.latestTxTime, isCompact && iStyles.latestTxTimeCompact, scaledCardStyles.latestTxTime, cardTextStyle]}
|
isCompact && iStyles.latestTxTimeCompact,
|
||||||
|
{ color: colors.inverseForegroundColor, writingDirection: direction },
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
{latestTransactionText}
|
{latestTransactionText}
|
||||||
</Text>
|
</Text>
|
||||||
@ -586,7 +541,7 @@ const WalletsCarousel = forwardRef<CarouselListRefType, WalletsCarouselProps>((p
|
|||||||
animateChanges = false,
|
animateChanges = false,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const { width, fontScale } = useWindowDimensions();
|
const { width } = useWindowDimensions();
|
||||||
const itemWidth = React.useMemo(() => getWalletCarouselItemWidth(width), [width]);
|
const itemWidth = React.useMemo(() => getWalletCarouselItemWidth(width), [width]);
|
||||||
const snapInterval = React.useMemo(() => itemWidth, [itemWidth]);
|
const snapInterval = React.useMemo(() => itemWidth, [itemWidth]);
|
||||||
const snapOffsets = React.useMemo(() => {
|
const snapOffsets = React.useMemo(() => {
|
||||||
@ -695,7 +650,7 @@ const WalletsCarousel = forwardRef<CarouselListRefType, WalletsCarouselProps>((p
|
|||||||
console.warn('[WalletsCarousel] Error scrolling to wallet:', error);
|
console.warn('[WalletsCarousel] Error scrolling to wallet:', error);
|
||||||
// Fallback: try scrolling to offset
|
// Fallback: try scrolling to offset
|
||||||
// Use different measurement based on orientation
|
// Use different measurement based on orientation
|
||||||
const itemSize = horizontal ? itemWidth : WALLET_CAROUSEL_HEIGHT;
|
const itemSize = horizontal ? itemWidth : 195; // 195 is the approximate height of wallet card
|
||||||
flatListRef.current.scrollToOffset({
|
flatListRef.current.scrollToOffset({
|
||||||
offset: itemSize * walletIndex,
|
offset: itemSize * walletIndex,
|
||||||
animated,
|
animated,
|
||||||
@ -817,7 +772,7 @@ const WalletsCarousel = forwardRef<CarouselListRefType, WalletsCarouselProps>((p
|
|||||||
|
|
||||||
const keyExtractor = useCallback((item: TWallet, index: number) => (item?.getID ? item.getID() : index.toString()), []);
|
const keyExtractor = useCallback((item: TWallet, index: number) => (item?.getID ? item.getID() : index.toString()), []);
|
||||||
|
|
||||||
const sliderHeight = getWalletCarouselHeight(fontScale);
|
const sliderHeight = 195;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@ -900,8 +855,7 @@ const WalletsCarousel = forwardRef<CarouselListRefType, WalletsCarouselProps>((p
|
|||||||
|
|
||||||
const cStyles = StyleSheet.create({
|
const cStyles = StyleSheet.create({
|
||||||
content: {
|
content: {
|
||||||
paddingTop: scaleLayoutUp(WALLET_CAROUSEL_PADDING_TOP, fontScale),
|
paddingTop: 16,
|
||||||
paddingBottom: scaleLayoutUp(WALLET_CAROUSEL_PADDING_BOTTOM, fontScale),
|
|
||||||
},
|
},
|
||||||
contentLargeScreen: {
|
contentLargeScreen: {
|
||||||
paddingHorizontal: sizeClass === SizeClass.Large ? 16 : 12,
|
paddingHorizontal: sizeClass === SizeClass.Large ? 16 : 12,
|
||||||
@ -932,7 +886,7 @@ const WalletsCarousel = forwardRef<CarouselListRefType, WalletsCarouselProps>((p
|
|||||||
automaticallyAdjustContentInsets
|
automaticallyAdjustContentInsets
|
||||||
automaticallyAdjustKeyboardInsets
|
automaticallyAdjustKeyboardInsets
|
||||||
automaticallyAdjustsScrollIndicatorInsets
|
automaticallyAdjustsScrollIndicatorInsets
|
||||||
style={{ minHeight: sliderHeight }}
|
style={{ minHeight: sliderHeight + 12 }}
|
||||||
onScrollToIndexFailed={onScrollToIndexFailed}
|
onScrollToIndexFailed={onScrollToIndexFailed}
|
||||||
ListFooterComponent={onNewWalletPress ? <NewWalletPanel onPress={onNewWalletPress} /> : null}
|
ListFooterComponent={onNewWalletPress ? <NewWalletPanel onPress={onNewWalletPress} /> : null}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@ -252,8 +252,8 @@ export default class SelfTest extends Component {
|
|||||||
//
|
//
|
||||||
|
|
||||||
const data2encrypt = 'really long data string';
|
const data2encrypt = 'really long data string';
|
||||||
const crypted = encryption.encrypt(data2encrypt, 'password');
|
const crypted = await encryption.encrypt(data2encrypt, 'password');
|
||||||
const decrypted = encryption.decrypt(crypted, 'password');
|
const decrypted = await encryption.decrypt(crypted, 'password');
|
||||||
|
|
||||||
if (decrypted !== data2encrypt) {
|
if (decrypted !== data2encrypt) {
|
||||||
throw new Error('encryption lib is not ok');
|
throw new Error('encryption lib is not ok');
|
||||||
|
|||||||
@ -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 { ActivityIndicator, BackHandler, Linking, StyleSheet, Text, TouchableOpacity, useWindowDimensions, View } from 'react-native';
|
||||||
import { sha256 } from '@noble/hashes/sha256';
|
import { sha256 } from '@noble/hashes/sha256';
|
||||||
import { RouteProp, useRoute } from '@react-navigation/native';
|
import { RouteProp, useRoute } from '@react-navigation/native';
|
||||||
import { NativeStackNavigationOptions, NativeStackNavigationProp } from '@react-navigation/native-stack';
|
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
import Icon from '../../components/Icon';
|
import Icon from '../../components/Icon';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||||
@ -63,10 +63,6 @@ enum ButtonStatus {
|
|||||||
type RouteProps = RouteProp<DetailViewStackParamList, 'TransactionStatus'>;
|
type RouteProps = RouteProp<DetailViewStackParamList, 'TransactionStatus'>;
|
||||||
type NavigationProps = NativeStackNavigationProp<DetailViewStackParamList, 'TransactionStatus'>;
|
type NavigationProps = NativeStackNavigationProp<DetailViewStackParamList, 'TransactionStatus'>;
|
||||||
|
|
||||||
type TransactionStatusHeaderOptions = NativeStackNavigationOptions & {
|
|
||||||
headerTitleContainerStyle?: { flex: number; maxWidth: number };
|
|
||||||
};
|
|
||||||
|
|
||||||
enum ActionType {
|
enum ActionType {
|
||||||
SetCPFPPossible,
|
SetCPFPPossible,
|
||||||
SetRBFBumpFeePossible,
|
SetRBFBumpFeePossible,
|
||||||
@ -140,12 +136,8 @@ type TransactionDetailHeaderTitleProps = {
|
|||||||
|
|
||||||
const TransactionDetailHeaderTitle: React.FC<TransactionDetailHeaderTitleProps> = ({ direction, date, directionStyle, dateStyle }) => (
|
const TransactionDetailHeaderTitle: React.FC<TransactionDetailHeaderTitleProps> = ({ direction, date, directionStyle, dateStyle }) => (
|
||||||
<View style={styles.headerTitleContainer}>
|
<View style={styles.headerTitleContainer}>
|
||||||
<BlueText style={directionStyle} numberOfLines={1} adjustsFontSizeToFit minimumFontScale={0.8}>
|
<BlueText style={directionStyle}>{direction}</BlueText>
|
||||||
{direction}
|
<BlueText style={dateStyle}>{date}</BlueText>
|
||||||
</BlueText>
|
|
||||||
<BlueText style={dateStyle} numberOfLines={2} adjustsFontSizeToFit minimumFontScale={0.8}>
|
|
||||||
{date}
|
|
||||||
</BlueText>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -161,57 +153,10 @@ const TransactionStatus: React.FC = () => {
|
|||||||
const subscribedWallet = useWalletSubscribe(walletID);
|
const subscribedWallet = useWalletSubscribe(walletID);
|
||||||
const { navigate, goBack, setOptions } = useExtendedNavigation<NavigationProps>();
|
const { navigate, goBack, setOptions } = useExtendedNavigation<NavigationProps>();
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
const { width: windowWidth, fontScale } = useWindowDimensions();
|
const { width: windowWidth } = useWindowDimensions();
|
||||||
const { selectedBlockExplorer } = useSettings();
|
const { selectedBlockExplorer } = useSettings();
|
||||||
const fetchTxInterval = useRef<NodeJS.Timeout | undefined>(undefined);
|
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)
|
// 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 detailValueMaxWidth = useMemo(() => Math.max(0, Math.floor((windowWidth - 48) / 2)), [windowWidth]);
|
||||||
const detailValueWidthStyle = useMemo(() => ({ width: detailValueMaxWidth }), [detailValueMaxWidth]);
|
const detailValueWidthStyle = useMemo(() => ({ width: detailValueMaxWidth }), [detailValueMaxWidth]);
|
||||||
@ -976,20 +921,15 @@ const TransactionStatus: React.FC = () => {
|
|||||||
<TransactionDetailHeaderTitle
|
<TransactionDetailHeaderTitle
|
||||||
direction={transactionDirection}
|
direction={transactionDirection}
|
||||||
date={transactionDate}
|
date={transactionDate}
|
||||||
directionStyle={[styles.headerTitleDirection, stylesHook.headerTitleDirection, scaledStyles.headerTitleDirection]}
|
directionStyle={[styles.headerTitleDirection, stylesHook.headerTitleDirection]}
|
||||||
dateStyle={[styles.headerTitleDate, stylesHook.titleDate, scaledStyles.headerTitleDate]}
|
dateStyle={[styles.headerTitleDate, stylesHook.titleDate]}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
headerTitleAlign: 'left',
|
});
|
||||||
headerTitleContainerStyle: {
|
|
||||||
flex: 1,
|
|
||||||
maxWidth: Math.max(0, windowWidth - 96),
|
|
||||||
},
|
|
||||||
} as TransactionStatusHeaderOptions);
|
|
||||||
}
|
}
|
||||||
// stylesHook is derived from colors; omitting to avoid unnecessary effect runs
|
// stylesHook is derived from colors; omitting to avoid unnecessary effect runs
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [tx, transactionDirection, transactionDate, setOptions, colors, windowWidth, scaledStyles]);
|
}, [tx, transactionDirection, transactionDate, setOptions, colors]);
|
||||||
|
|
||||||
if (loadingError) {
|
if (loadingError) {
|
||||||
return (
|
return (
|
||||||
@ -1022,20 +962,15 @@ const TransactionStatus: React.FC = () => {
|
|||||||
{/* Value Section */}
|
{/* Value Section */}
|
||||||
<View style={styles.valueCard}>
|
<View style={styles.valueCard}>
|
||||||
<View style={styles.valueContent}>
|
<View style={styles.valueContent}>
|
||||||
<Text
|
<Text style={[styles.value, stylesHook.value]} selectable numberOfLines={1} adjustsFontSizeToFit minimumFontScale={0.55}>
|
||||||
style={[styles.value, stylesHook.value, scaledStyles.value, styles.valueFullWidth]}
|
|
||||||
selectable
|
|
||||||
numberOfLines={1}
|
|
||||||
adjustsFontSizeToFit
|
|
||||||
minimumFontScale={0.55}
|
|
||||||
>
|
|
||||||
{txValue !== null ? formatBalanceWithoutSuffix(txValue, preferredBalanceUnit, true) : '-'}
|
{txValue !== null ? formatBalanceWithoutSuffix(txValue, preferredBalanceUnit, true) : '-'}
|
||||||
|
{` `}
|
||||||
{preferredBalanceUnit !== BitcoinUnit.LOCAL_CURRENCY && (
|
{preferredBalanceUnit !== BitcoinUnit.LOCAL_CURRENCY && (
|
||||||
<Text style={[styles.valueUnit, stylesHook.valueUnit]}>{` ${preferredBalanceUnit}`}</Text>
|
<Text style={[styles.valueUnit, stylesHook.valueUnit]}>{preferredBalanceUnit}</Text>
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
{txValue !== null && (
|
{txValue !== null && (
|
||||||
<Text style={[styles.localCurrency, stylesHook.localCurrency, scaledStyles.localCurrency]}>
|
<Text style={[styles.localCurrency, stylesHook.localCurrency]}>
|
||||||
{preferredBalanceUnit === BitcoinUnit.LOCAL_CURRENCY
|
{preferredBalanceUnit === BitcoinUnit.LOCAL_CURRENCY
|
||||||
? `${formatBalanceWithoutSuffix(Math.abs(txValue), BitcoinUnit.BTC, true)} ${BitcoinUnit.BTC}`
|
? `${formatBalanceWithoutSuffix(Math.abs(txValue), BitcoinUnit.BTC, true)} ${BitcoinUnit.BTC}`
|
||||||
: satoshiToLocalCurrency(Math.abs(txValue))}
|
: satoshiToLocalCurrency(Math.abs(txValue))}
|
||||||
@ -1061,10 +996,8 @@ const TransactionStatus: React.FC = () => {
|
|||||||
<View style={styles.stateIndicator}>
|
<View style={styles.stateIndicator}>
|
||||||
<TransactionPendingIcon />
|
<TransactionPendingIcon />
|
||||||
<View style={styles.stateLabelContainer}>
|
<View style={styles.stateLabelContainer}>
|
||||||
<BlueText style={[styles.stateLabel, stylesHook.stateLabelPending, scaledStyles.stateLabel]}>
|
<BlueText style={[styles.stateLabel, stylesHook.stateLabelPending]}>{loc.transactions.pending}</BlueText>
|
||||||
{loc.transactions.pending}
|
<BlueText style={[styles.stateValue, stylesHook.stateValuePending, styles.stateValueInline]}>
|
||||||
</BlueText>
|
|
||||||
<BlueText style={[styles.stateValue, stylesHook.stateValuePending, styles.stateValueInline, scaledStyles.stateValue]}>
|
|
||||||
{eta || loc.transactions.details_eta_analyzing}
|
{eta || loc.transactions.details_eta_analyzing}
|
||||||
</BlueText>
|
</BlueText>
|
||||||
</View>
|
</View>
|
||||||
@ -1096,11 +1029,9 @@ const TransactionStatus: React.FC = () => {
|
|||||||
<View style={styles.stateIndicator}>
|
<View style={styles.stateIndicator}>
|
||||||
<TransactionOutgoingIcon />
|
<TransactionOutgoingIcon />
|
||||||
<View style={styles.stateLabelContainer}>
|
<View style={styles.stateLabelContainer}>
|
||||||
<BlueText style={[styles.stateLabel, stylesHook.stateLabelSent, scaledStyles.stateLabel]}>
|
<BlueText style={[styles.stateLabel, stylesHook.stateLabelSent]}>{loc.transactions.details_sent}</BlueText>
|
||||||
{loc.transactions.details_sent}
|
|
||||||
</BlueText>
|
|
||||||
{isOnChainTx && (
|
{isOnChainTx && (
|
||||||
<BlueText style={[styles.stateValue, stylesHook.stateValueSent, styles.stateValueInline, scaledStyles.stateValue]}>
|
<BlueText style={[styles.stateValue, stylesHook.stateValueSent, styles.stateValueInline]}>
|
||||||
{loc.formatString(loc.transactions.confirmations_lowercase, {
|
{loc.formatString(loc.transactions.confirmations_lowercase, {
|
||||||
confirmations: parsedConfirmations > 6 ? '6+' : parsedConfirmations,
|
confirmations: parsedConfirmations > 6 ? '6+' : parsedConfirmations,
|
||||||
})}
|
})}
|
||||||
@ -1112,11 +1043,9 @@ const TransactionStatus: React.FC = () => {
|
|||||||
<View style={styles.stateIndicator}>
|
<View style={styles.stateIndicator}>
|
||||||
<TransactionIncomingIcon />
|
<TransactionIncomingIcon />
|
||||||
<View style={styles.stateLabelContainer}>
|
<View style={styles.stateLabelContainer}>
|
||||||
<BlueText style={[styles.stateLabel, stylesHook.stateLabelReceived, scaledStyles.stateLabel]}>
|
<BlueText style={[styles.stateLabel, stylesHook.stateLabelReceived]}>{loc.transactions.details_received}</BlueText>
|
||||||
{loc.transactions.details_received}
|
|
||||||
</BlueText>
|
|
||||||
{isOnChainTx && (
|
{isOnChainTx && (
|
||||||
<BlueText style={[styles.stateValue, stylesHook.stateValueReceived, styles.stateValueInline, scaledStyles.stateValue]}>
|
<BlueText style={[styles.stateValue, stylesHook.stateValueReceived, styles.stateValueInline]}>
|
||||||
{loc.formatString(loc.transactions.confirmations_lowercase, {
|
{loc.formatString(loc.transactions.confirmations_lowercase, {
|
||||||
confirmations: parsedConfirmations > 6 ? '6+' : parsedConfirmations,
|
confirmations: parsedConfirmations > 6 ? '6+' : parsedConfirmations,
|
||||||
})}
|
})}
|
||||||
@ -1151,29 +1080,20 @@ const TransactionStatus: React.FC = () => {
|
|||||||
{/* Details Section */}
|
{/* Details Section */}
|
||||||
<View style={[styles.detailsCard, stylesHook.detailsCard]}>
|
<View style={[styles.detailsCard, stylesHook.detailsCard]}>
|
||||||
{/* Details Title */}
|
{/* Details Title */}
|
||||||
<View style={[styles.sectionTitle, styles.sectionTitleWithButton, stylesHook.sectionTitle, scaledStyles.sectionTitle]}>
|
<View style={[styles.sectionTitle, styles.sectionTitleWithButton, stylesHook.sectionTitle]}>
|
||||||
<BlueText style={[styles.sectionTitleText, stylesHook.sectionTitleText, styles.sectionTitleTextFlexible]}>
|
<BlueText style={[styles.sectionTitleText, stylesHook.sectionTitleText]}>{loc.transactions.details_section}</BlueText>
|
||||||
{loc.transactions.details_section}
|
|
||||||
</BlueText>
|
|
||||||
{tx?.hash && (
|
{tx?.hash && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={handleOpenBlockExplorer}
|
onPress={handleOpenBlockExplorer}
|
||||||
style={[styles.explorerButton, stylesHook.explorerButton, scaledStyles.explorerButton]}
|
style={[styles.explorerButton, stylesHook.explorerButton]}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<BlueText
|
<BlueText style={[styles.explorerButtonText, stylesHook.explorerButtonText]}>{loc.transactions.details_explorer}</BlueText>
|
||||||
style={[styles.explorerButtonText, stylesHook.explorerButtonText]}
|
|
||||||
numberOfLines={1}
|
|
||||||
adjustsFontSizeToFit
|
|
||||||
minimumFontScale={0.8}
|
|
||||||
>
|
|
||||||
{loc.transactions.details_explorer}
|
|
||||||
</BlueText>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
{/* Network Fee */}
|
{/* Network Fee */}
|
||||||
<View style={[styles.detailRow, stylesHook.detailRow, scaledStyles.detailRow]}>
|
<View style={[styles.detailRow, stylesHook.detailRow]}>
|
||||||
<BlueText style={[styles.detailLabel, stylesHook.detailLabel]}>{loc.transactions.details_network_fee}</BlueText>
|
<BlueText style={[styles.detailLabel, stylesHook.detailLabel]}>{loc.transactions.details_network_fee}</BlueText>
|
||||||
<View style={styles.detailValueContainer}>
|
<View style={styles.detailValueContainer}>
|
||||||
<CopyTextToClipboard
|
<CopyTextToClipboard
|
||||||
@ -1197,7 +1117,7 @@ const TransactionStatus: React.FC = () => {
|
|||||||
const displayText = externalAddresses.map(shortenCounterpartyName).join(', ');
|
const displayText = externalAddresses.map(shortenCounterpartyName).join(', ');
|
||||||
const copyText = externalAddresses.join(', ');
|
const copyText = externalAddresses.join(', ');
|
||||||
return (
|
return (
|
||||||
<View style={[styles.detailRow, stylesHook.detailRow, scaledStyles.detailRow]}>
|
<View style={[styles.detailRow, stylesHook.detailRow]}>
|
||||||
<BlueText style={[styles.detailLabel, stylesHook.detailLabel]}>{loc.transactions.details_to_address}</BlueText>
|
<BlueText style={[styles.detailLabel, stylesHook.detailLabel]}>{loc.transactions.details_to_address}</BlueText>
|
||||||
<View style={styles.detailValueContainer}>
|
<View style={styles.detailValueContainer}>
|
||||||
<View style={styles.detailValueCopyContainer}>
|
<View style={styles.detailValueCopyContainer}>
|
||||||
@ -1223,7 +1143,7 @@ const TransactionStatus: React.FC = () => {
|
|||||||
|
|
||||||
{/* Transaction ID - display shortened so it stays on one line on Android; copy still gets full hash */}
|
{/* Transaction ID - display shortened so it stays on one line on Android; copy still gets full hash */}
|
||||||
{tx.hash && (
|
{tx.hash && (
|
||||||
<View style={[styles.detailRow, stylesHook.detailRow, scaledStyles.detailRow]}>
|
<View style={[styles.detailRow, stylesHook.detailRow]}>
|
||||||
<BlueText style={[styles.detailLabel, stylesHook.detailLabel]}>{loc.transactions.details_id}</BlueText>
|
<BlueText style={[styles.detailLabel, stylesHook.detailLabel]}>{loc.transactions.details_id}</BlueText>
|
||||||
<View style={styles.detailValueContainer}>
|
<View style={styles.detailValueContainer}>
|
||||||
<View style={styles.detailValueCopyContainer}>
|
<View style={styles.detailValueCopyContainer}>
|
||||||
@ -1250,7 +1170,7 @@ const TransactionStatus: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Note/Memo */}
|
{/* Note/Memo */}
|
||||||
<View style={[styles.detailRow, styles.detailRowLast, stylesHook.detailRow, scaledStyles.detailRow]}>
|
<View style={[styles.detailRow, styles.detailRowLast, stylesHook.detailRow]}>
|
||||||
<BlueText style={[styles.detailLabel, stylesHook.detailLabel]}>{loc.transactions.details_note}</BlueText>
|
<BlueText style={[styles.detailLabel, stylesHook.detailLabel]}>{loc.transactions.details_note}</BlueText>
|
||||||
<View style={styles.detailValueContainer}>
|
<View style={styles.detailValueContainer}>
|
||||||
{memo ? (
|
{memo ? (
|
||||||
@ -1260,19 +1180,8 @@ const TransactionStatus: React.FC = () => {
|
|||||||
</BlueText>
|
</BlueText>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
) : (
|
) : (
|
||||||
<TouchableOpacity
|
<TouchableOpacity onPress={handleNotePress} style={[styles.addButton, stylesHook.addButton]} activeOpacity={0.7}>
|
||||||
onPress={handleNotePress}
|
<BlueText style={[styles.addButtonText, stylesHook.addButtonText]}>{loc.transactions.details_add_note}</BlueText>
|
||||||
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>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@ -1283,13 +1192,11 @@ const TransactionStatus: React.FC = () => {
|
|||||||
<View style={[styles.detailsCard, stylesHook.detailsCard]}>
|
<View style={[styles.detailsCard, stylesHook.detailsCard]}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => setIsAdvancedExpanded(!isAdvancedExpanded)}
|
onPress={() => setIsAdvancedExpanded(!isAdvancedExpanded)}
|
||||||
style={[styles.advancedHeader, stylesHook.advancedHeader, scaledStyles.advancedHeader]}
|
style={[styles.advancedHeader, stylesHook.advancedHeader]}
|
||||||
activeOpacity={0.85}
|
activeOpacity={0.85}
|
||||||
>
|
>
|
||||||
<View style={[styles.sectionTitle, stylesHook.sectionTitle, styles.sectionTitleRow, scaledStyles.sectionTitle]}>
|
<View style={[styles.sectionTitle, stylesHook.sectionTitle, styles.sectionTitleRow]}>
|
||||||
<BlueText style={[styles.sectionTitleText, stylesHook.sectionTitleText, styles.sectionTitleTextFlexible]} numberOfLines={2}>
|
<BlueText style={[styles.sectionTitleText, stylesHook.sectionTitleText]}>{loc.transactions.details_advanced}</BlueText>
|
||||||
{loc.transactions.details_advanced}
|
|
||||||
</BlueText>
|
|
||||||
<Icon
|
<Icon
|
||||||
name={isAdvancedExpanded ? 'chevron-up' : 'chevron-down'}
|
name={isAdvancedExpanded ? 'chevron-up' : 'chevron-down'}
|
||||||
type="font-awesome"
|
type="font-awesome"
|
||||||
@ -1302,7 +1209,7 @@ const TransactionStatus: React.FC = () => {
|
|||||||
{isAdvancedExpanded && (
|
{isAdvancedExpanded && (
|
||||||
<View style={[styles.advancedContent, stylesHook.advancedContent]}>
|
<View style={[styles.advancedContent, stylesHook.advancedContent]}>
|
||||||
{/* Fee Rate */}
|
{/* Fee Rate */}
|
||||||
<View style={[styles.detailRow, stylesHook.detailRow, scaledStyles.detailRow]}>
|
<View style={[styles.detailRow, stylesHook.detailRow]}>
|
||||||
<BlueText style={[styles.detailLabel, stylesHook.detailLabel]}>{loc.transactions.details_fee_rate}</BlueText>
|
<BlueText style={[styles.detailLabel, stylesHook.detailLabel]}>{loc.transactions.details_fee_rate}</BlueText>
|
||||||
<View style={styles.detailValueContainer}>
|
<View style={styles.detailValueContainer}>
|
||||||
<CopyTextToClipboard
|
<CopyTextToClipboard
|
||||||
@ -1314,7 +1221,7 @@ const TransactionStatus: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Size */}
|
{/* Size */}
|
||||||
<View style={[styles.detailRow, stylesHook.detailRow, scaledStyles.detailRow]}>
|
<View style={[styles.detailRow, stylesHook.detailRow]}>
|
||||||
<BlueText style={[styles.detailLabel, stylesHook.detailLabel]}>{loc.transactions.details_size}</BlueText>
|
<BlueText style={[styles.detailLabel, stylesHook.detailLabel]}>{loc.transactions.details_size}</BlueText>
|
||||||
<View style={styles.detailValueContainer}>
|
<View style={styles.detailValueContainer}>
|
||||||
<CopyTextToClipboard
|
<CopyTextToClipboard
|
||||||
@ -1326,7 +1233,7 @@ const TransactionStatus: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Virtual Size */}
|
{/* Virtual Size */}
|
||||||
<View style={[styles.detailRow, stylesHook.detailRow, scaledStyles.detailRow]}>
|
<View style={[styles.detailRow, stylesHook.detailRow]}>
|
||||||
<BlueText style={[styles.detailLabel, stylesHook.detailLabel]}>{loc.transactions.details_virtual_size}</BlueText>
|
<BlueText style={[styles.detailLabel, stylesHook.detailLabel]}>{loc.transactions.details_virtual_size}</BlueText>
|
||||||
<View style={styles.detailValueContainer}>
|
<View style={styles.detailValueContainer}>
|
||||||
<CopyTextToClipboard
|
<CopyTextToClipboard
|
||||||
@ -1338,7 +1245,7 @@ const TransactionStatus: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Transaction Hex */}
|
{/* Transaction Hex */}
|
||||||
<View style={[styles.detailRow, stylesHook.detailRow, scaledStyles.detailRow]}>
|
<View style={[styles.detailRow, stylesHook.detailRow]}>
|
||||||
<BlueText style={[styles.detailLabel, stylesHook.detailLabel]}>{loc.transactions.details_tx_hex}</BlueText>
|
<BlueText style={[styles.detailLabel, stylesHook.detailLabel]}>{loc.transactions.details_tx_hex}</BlueText>
|
||||||
<View style={styles.detailValueContainer}>
|
<View style={styles.detailValueContainer}>
|
||||||
{txHex ? (
|
{txHex ? (
|
||||||
@ -1403,7 +1310,6 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'flex-start',
|
alignItems: 'flex-start',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 0,
|
|
||||||
},
|
},
|
||||||
headerTitleDirection: {
|
headerTitleDirection: {
|
||||||
fontSize: 17,
|
fontSize: 17,
|
||||||
@ -1451,20 +1357,15 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'flex-start',
|
alignItems: 'flex-start',
|
||||||
justifyContent: 'flex-start',
|
justifyContent: 'flex-start',
|
||||||
overflow: 'visible',
|
overflow: 'visible',
|
||||||
width: '100%',
|
|
||||||
},
|
},
|
||||||
value: {
|
value: {
|
||||||
fontSize: 40,
|
fontSize: 40,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
letterSpacing: -0.5,
|
letterSpacing: -0.5,
|
||||||
lineHeight: 48,
|
lineHeight: 32,
|
||||||
paddingTop: 8,
|
paddingTop: 8,
|
||||||
minHeight: 38,
|
minHeight: 38,
|
||||||
},
|
},
|
||||||
valueFullWidth: {
|
|
||||||
width: '100%',
|
|
||||||
flexShrink: 1,
|
|
||||||
},
|
|
||||||
valueUnit: {
|
valueUnit: {
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
@ -1482,6 +1383,7 @@ const styles = StyleSheet.create({
|
|||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
marginHorizontal: 24,
|
marginHorizontal: 24,
|
||||||
marginBottom: 42,
|
marginBottom: 42,
|
||||||
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
stateSection: {
|
stateSection: {
|
||||||
alignItems: 'flex-start',
|
alignItems: 'flex-start',
|
||||||
@ -1499,7 +1401,6 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'flex-start',
|
alignItems: 'flex-start',
|
||||||
marginLeft: 8,
|
marginLeft: 8,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 0,
|
|
||||||
},
|
},
|
||||||
stateLabel: {
|
stateLabel: {
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
@ -1585,23 +1486,17 @@ const styles = StyleSheet.create({
|
|||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 8,
|
|
||||||
},
|
},
|
||||||
sectionTitleText: {
|
sectionTitleText: {
|
||||||
fontSize: 17,
|
fontSize: 17,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
sectionTitleTextFlexible: {
|
|
||||||
flex: 1,
|
|
||||||
flexShrink: 1,
|
|
||||||
minWidth: 0,
|
|
||||||
},
|
|
||||||
explorerButton: {
|
explorerButton: {
|
||||||
paddingVertical: 6,
|
paddingVertical: 6,
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 12,
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
alignSelf: 'flex-end',
|
alignSelf: 'flex-end',
|
||||||
flexShrink: 0,
|
minWidth: 50,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
@ -1612,7 +1507,7 @@ const styles = StyleSheet.create({
|
|||||||
detailRow: {
|
detailRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'flex-start',
|
alignItems: 'center',
|
||||||
marginBottom: 0,
|
marginBottom: 0,
|
||||||
minHeight: 24,
|
minHeight: 24,
|
||||||
paddingVertical: 12,
|
paddingVertical: 12,
|
||||||
@ -1636,8 +1531,6 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
flexShrink: 1,
|
|
||||||
minWidth: 0,
|
|
||||||
lineHeight: 22,
|
lineHeight: 22,
|
||||||
paddingRight: 12,
|
paddingRight: 12,
|
||||||
},
|
},
|
||||||
@ -1651,12 +1544,11 @@ const styles = StyleSheet.create({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
flexWrap: 'wrap',
|
flexWrap: 'nowrap',
|
||||||
alignItems: 'flex-end',
|
alignItems: 'center',
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'flex-end',
|
justifyContent: 'flex-end',
|
||||||
gap: 8,
|
gap: 8,
|
||||||
flexShrink: 0,
|
|
||||||
},
|
},
|
||||||
detailValueCopyContainer: {
|
detailValueCopyContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@ -1704,7 +1596,7 @@ const styles = StyleSheet.create({
|
|||||||
paddingHorizontal: 12,
|
paddingHorizontal: 12,
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
alignSelf: 'flex-end',
|
alignSelf: 'flex-end',
|
||||||
flexShrink: 0,
|
minWidth: 50,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
@ -1722,6 +1614,7 @@ const styles = StyleSheet.create({
|
|||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderTopLeftRadius: 12,
|
borderTopLeftRadius: 12,
|
||||||
borderTopRightRadius: 12,
|
borderTopRightRadius: 12,
|
||||||
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
advancedContent: {
|
advancedContent: {
|
||||||
marginTop: 0,
|
marginTop: 0,
|
||||||
|
|||||||
@ -33,7 +33,6 @@ import presentAlert, { AlertType } from '../../components/Alert';
|
|||||||
import { FButton, FContainer, FloatButtonsBottomFade } from '../../components/FloatButtons';
|
import { FButton, FContainer, FloatButtonsBottomFade } from '../../components/FloatButtons';
|
||||||
import { useTheme } from '../../components/themes';
|
import { useTheme } from '../../components/themes';
|
||||||
import { TransactionListItem } from '../../components/TransactionListItem';
|
import { TransactionListItem } from '../../components/TransactionListItem';
|
||||||
import { TX_ROW_BASE_HEIGHT } from '../../components/ListItem';
|
|
||||||
import TransactionsNavigationHeader, { actionKeys } from '../../components/TransactionsNavigationHeader';
|
import TransactionsNavigationHeader, { actionKeys } from '../../components/TransactionsNavigationHeader';
|
||||||
import { unlockWithBiometrics, useBiometrics } from '../../hooks/useBiometrics';
|
import { unlockWithBiometrics, useBiometrics } from '../../hooks/useBiometrics';
|
||||||
import { useExtendedNavigation } from '../../hooks/useExtendedNavigation';
|
import { useExtendedNavigation } from '../../hooks/useExtendedNavigation';
|
||||||
@ -438,17 +437,11 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
|
|||||||
[name, navigate, navigation, onWalletSelect, walletID, wallets],
|
[name, navigate, navigation, onWalletSelect, walletID, wallets],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { fontScale } = useWindowDimensions();
|
const getItemLayout = (_: any, index: number) => ({
|
||||||
const txRowHeight = Math.round(TX_ROW_BASE_HEIGHT * fontScale);
|
length: 64,
|
||||||
|
offset: 64 * index,
|
||||||
const getItemLayout = useCallback(
|
index,
|
||||||
(_: any, index: number) => ({
|
});
|
||||||
length: txRowHeight,
|
|
||||||
offset: txRowHeight * index,
|
|
||||||
index,
|
|
||||||
}),
|
|
||||||
[txRowHeight],
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderItem = useCallback(
|
const renderItem = useCallback(
|
||||||
// react/no-unused-prop-types misfires on inline arrow renderers: it reads the
|
// react/no-unused-prop-types misfires on inline arrow renderers: it reads the
|
||||||
|
|||||||
@ -8,15 +8,10 @@ import triggerHapticFeedback, { HapticFeedbackTypes } from '../../blue_modules/h
|
|||||||
import DeeplinkSchemaMatch from '../../class/deeplink-schema-match';
|
import DeeplinkSchemaMatch from '../../class/deeplink-schema-match';
|
||||||
import { ExtendedTransaction, Transaction, TWallet } from '../../class/wallets/types';
|
import { ExtendedTransaction, Transaction, TWallet } from '../../class/wallets/types';
|
||||||
import presentAlert from '../../components/Alert';
|
import presentAlert from '../../components/Alert';
|
||||||
import { FButton, FContainer, FloatButtonsBottomFade, getFloatingButtonReservedHeight } from '../../components/FloatButtons';
|
import { FButton, FContainer, FloatButtonsBottomFade } from '../../components/FloatButtons';
|
||||||
import { useTheme } from '../../components/themes';
|
import { useTheme } from '../../components/themes';
|
||||||
import { TransactionListItem } from '../../components/TransactionListItem';
|
import { TransactionListItem } from '../../components/TransactionListItem';
|
||||||
import { TX_ROW_BASE_HEIGHT } from '../../components/ListItem';
|
import WalletsCarousel, { getWalletCarouselItemWidth, CarouselListRefType } from '../../components/WalletsCarousel';
|
||||||
import WalletsCarousel, {
|
|
||||||
getWalletCarouselItemWidth,
|
|
||||||
CarouselListRefType,
|
|
||||||
getWalletCarouselHeight,
|
|
||||||
} from '../../components/WalletsCarousel';
|
|
||||||
import { useSizeClass, SizeClass } from '../../blue_modules/sizeClass';
|
import { useSizeClass, SizeClass } from '../../blue_modules/sizeClass';
|
||||||
import loc from '../../loc';
|
import loc from '../../loc';
|
||||||
import ActionSheet from '../ActionSheet';
|
import ActionSheet from '../ActionSheet';
|
||||||
@ -33,7 +28,6 @@ import { scanQrHelper } from '../../helpers/scan-qr';
|
|||||||
import { isIOS26OrHigher } from '../../components/platform';
|
import { isIOS26OrHigher } from '../../components/platform';
|
||||||
|
|
||||||
const WalletsListSections = { CAROUSEL: 'CAROUSEL', TRANSACTIONS: 'TRANSACTIONS' };
|
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. */
|
/** 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;
|
const ELECTRUM_HEALTH_POLL_WHILE_WALLETS_LIST_FOCUSED_MS = 30_000;
|
||||||
@ -114,11 +108,7 @@ const WalletsList: React.FC = () => {
|
|||||||
const { registerTransactionsHandler, unregisterTransactionsHandler } = useMenuElements();
|
const { registerTransactionsHandler, unregisterTransactionsHandler } = useMenuElements();
|
||||||
const { wallets, getTransactions, refreshAllWalletTransactions } = useStorage();
|
const { wallets, getTransactions, refreshAllWalletTransactions } = useStorage();
|
||||||
const { isTotalBalanceEnabled, isElectrumDisabled } = useSettings();
|
const { isTotalBalanceEnabled, isElectrumDisabled } = useSettings();
|
||||||
const { width, fontScale } = useWindowDimensions();
|
const { width } = 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 { colors, scanImage } = useTheme();
|
||||||
const navigation = useExtendedNavigation<NavigationProps>();
|
const navigation = useExtendedNavigation<NavigationProps>();
|
||||||
const isFocused = useIsFocused();
|
const isFocused = useIsFocused();
|
||||||
@ -134,11 +124,9 @@ const WalletsList: React.FC = () => {
|
|||||||
listHeaderBack: {
|
listHeaderBack: {
|
||||||
backgroundColor: colors.background,
|
backgroundColor: colors.background,
|
||||||
paddingTop: sizeClass === SizeClass.Large ? 8 : 0,
|
paddingTop: sizeClass === SizeClass.Large ? 8 : 0,
|
||||||
minHeight: sectionHeaderHeight,
|
|
||||||
},
|
},
|
||||||
listHeaderText: {
|
listHeaderText: {
|
||||||
color: colors.foregroundColor,
|
color: colors.foregroundColor,
|
||||||
marginVertical: Math.round(16 * fontScale),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -505,9 +493,14 @@ const WalletsList: React.FC = () => {
|
|||||||
}, [sizeClass, dataSource]);
|
}, [sizeClass, dataSource]);
|
||||||
|
|
||||||
// Constants for layout calculations
|
// 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(() => {
|
const getSectionHeaderHeight = useCallback(() => {
|
||||||
return sectionHeaderHeight + (sizeClass === SizeClass.Large ? Math.round(20 * fontScale) : 0);
|
return SECTION_HEADER_HEIGHT + (sizeClass === SizeClass.Large ? LARGE_TITLE_EXTRA_HEIGHT : 0);
|
||||||
}, [sizeClass, sectionHeaderHeight, fontScale]);
|
}, [sizeClass]);
|
||||||
|
|
||||||
const getItemLayout = useCallback(
|
const getItemLayout = useCallback(
|
||||||
(data: any, index: number) => {
|
(data: any, index: number) => {
|
||||||
@ -516,8 +509,8 @@ const WalletsList: React.FC = () => {
|
|||||||
if (sizeClass === SizeClass.Large) {
|
if (sizeClass === SizeClass.Large) {
|
||||||
// On large screens: only transaction items, no carousel
|
// On large screens: only transaction items, no carousel
|
||||||
return {
|
return {
|
||||||
length: transactionItemHeight,
|
length: TRANSACTION_ITEM_HEIGHT,
|
||||||
offset: transactionItemHeight * index,
|
offset: TRANSACTION_ITEM_HEIGHT * index,
|
||||||
index,
|
index,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
@ -525,7 +518,7 @@ const WalletsList: React.FC = () => {
|
|||||||
// First section: Carousel
|
// First section: Carousel
|
||||||
if (index === 0) {
|
if (index === 0) {
|
||||||
return {
|
return {
|
||||||
length: carouselHeight,
|
length: CAROUSEL_HEIGHT,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
index,
|
index,
|
||||||
};
|
};
|
||||||
@ -538,13 +531,13 @@ const WalletsList: React.FC = () => {
|
|||||||
// 3. Transaction items
|
// 3. Transaction items
|
||||||
const transactionIndex = index - 1; // Adjust index to account for carousel
|
const transactionIndex = index - 1; // Adjust index to account for carousel
|
||||||
return {
|
return {
|
||||||
length: transactionItemHeight,
|
length: TRANSACTION_ITEM_HEIGHT,
|
||||||
offset: carouselHeight + headerHeight + transactionItemHeight * transactionIndex,
|
offset: CAROUSEL_HEIGHT + headerHeight + TRANSACTION_ITEM_HEIGHT * transactionIndex,
|
||||||
index,
|
index,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[sizeClass, getSectionHeaderHeight, carouselHeight, transactionItemHeight],
|
[sizeClass, getSectionHeaderHeight],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -557,7 +550,7 @@ const WalletsList: React.FC = () => {
|
|||||||
initialNumToRender={10}
|
initialNumToRender={10}
|
||||||
renderSectionFooter={renderSectionFooter}
|
renderSectionFooter={renderSectionFooter}
|
||||||
sections={sections}
|
sections={sections}
|
||||||
floatingButtonHeight={floatingButtonHeight}
|
floatingButtonHeight={70}
|
||||||
maxToRenderPerBatch={10}
|
maxToRenderPerBatch={10}
|
||||||
updateCellsBatchingPeriod={50}
|
updateCellsBatchingPeriod={50}
|
||||||
getItemLayout={getItemLayout}
|
getItemLayout={getItemLayout}
|
||||||
|
|||||||
@ -2,49 +2,49 @@ import assert from 'assert';
|
|||||||
|
|
||||||
import * as c from '../../blue_modules/encryption';
|
import * as c from '../../blue_modules/encryption';
|
||||||
|
|
||||||
|
jest.setTimeout(30 * 1000); // scrypt KDF (N=2^15) is ~200–400 ms per call
|
||||||
|
|
||||||
describe('unit - encryption', function () {
|
describe('unit - encryption', function () {
|
||||||
it('encrypts and decrypts', function () {
|
it('encrypts and decrypts', async function () {
|
||||||
const data2encrypt = 'really long data string bla bla really long data string bla bla really long data string bla bla';
|
const data2encrypt = 'really long data string bla bla really long data string bla bla really long data string bla bla';
|
||||||
const crypted = c.encrypt(data2encrypt, 'password');
|
const crypted = await c.encrypt(data2encrypt, 'password');
|
||||||
const decrypted = c.decrypt(crypted, 'password');
|
const decrypted = await c.decrypt(crypted, 'password');
|
||||||
|
|
||||||
assert.ok(crypted);
|
assert.ok(crypted);
|
||||||
assert.ok(decrypted);
|
assert.ok(decrypted);
|
||||||
assert.strictEqual(decrypted, data2encrypt);
|
assert.strictEqual(decrypted, data2encrypt);
|
||||||
assert.ok(crypted !== data2encrypt);
|
assert.ok(crypted !== data2encrypt);
|
||||||
|
assert.ok(crypted.startsWith('v2:'), 'new encryptions must use the v2 envelope');
|
||||||
|
|
||||||
let decryptedWithBadPassword;
|
const decryptedWithBadPassword = await c.decrypt(crypted, 'passwordBad');
|
||||||
try {
|
assert.strictEqual(decryptedWithBadPassword, false);
|
||||||
decryptedWithBadPassword = c.decrypt(crypted, 'passwordBad');
|
|
||||||
} catch (e) {}
|
|
||||||
assert.ok(!decryptedWithBadPassword);
|
|
||||||
|
|
||||||
let exceptionRaised = false;
|
let exceptionRaised = false;
|
||||||
try {
|
try {
|
||||||
c.encrypt('yolo', 'password');
|
await c.encrypt('yolo', 'password');
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
exceptionRaised = true;
|
exceptionRaised = true;
|
||||||
}
|
}
|
||||||
assert.ok(exceptionRaised);
|
assert.ok(exceptionRaised);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles ok malformed data', function () {
|
it('handles ok malformed data', async function () {
|
||||||
const decrypted = c.decrypt(
|
const decrypted = await c.decrypt(
|
||||||
'U2FsdGVkX1/OSNdi0JrLANn9qdNEiXgP20MJgT13CMKC7xKe+sb7x0An6r8lzrYeL2vjoPm2Xi5I3UdBcsgjgh0TR4PypNdDaW1tW8LhFH1wVCh1hacrFsJjoKMBmdCn4IVMwtIffGPptqBrGZl+6kjOc3BBbgq4uaAavFIwTS86WdaRt9qAboBcoPJZxsj37othbZfZfl2GBTCWnR1tOYAbElKWv4lBwNQpX7HqX3wTQkAbamBslsH5FfZRY1c38lOHrZMwNSyxhgspydksTxKkhPqWQu3XWT4GpRoRuVvYlBNvJOCUu2JbiVSp4NiOMSfnA8ahvpCGRNy+qPWsXqmJtz9BwyzedzDkgg6QOqxXz4oOeEJa/XLKiuv3ItsLrZb+sSA6wjB1Cx6/Oh2vW7eiHjCITeC7KUK1fAxVwufLcprNkvG8qFzkOcHxDyzG+sNL0cMipAxhpMX7qIcYcZFoLYkQRQHpOZKZCIAdNTfPGJ7M4cxGM0V+Uuirjyn+KAPJwNElwmPpX8sTQyEqlIlEwVjFXBpz28N5RAGN2zzCzEjD8NVYQJ2QyHj0gfWe',
|
'U2FsdGVkX1/OSNdi0JrLANn9qdNEiXgP20MJgT13CMKC7xKe+sb7x0An6r8lzrYeL2vjoPm2Xi5I3UdBcsgjgh0TR4PypNdDaW1tW8LhFH1wVCh1hacrFsJjoKMBmdCn4IVMwtIffGPptqBrGZl+6kjOc3BBbgq4uaAavFIwTS86WdaRt9qAboBcoPJZxsj37othbZfZfl2GBTCWnR1tOYAbElKWv4lBwNQpX7HqX3wTQkAbamBslsH5FfZRY1c38lOHrZMwNSyxhgspydksTxKkhPqWQu3XWT4GpRoRuVvYlBNvJOCUu2JbiVSp4NiOMSfnA8ahvpCGRNy+qPWsXqmJtz9BwyzedzDkgg6QOqxXz4oOeEJa/XLKiuv3ItsLrZb+sSA6wjB1Cx6/Oh2vW7eiHjCITeC7KUK1fAxVwufLcprNkvG8qFzkOcHxDyzG+sNL0cMipAxhpMX7qIcYcZFoLYkQRQHpOZKZCIAdNTfPGJ7M4cxGM0V+Uuirjyn+KAPJwNElwmPpX8sTQyEqlIlEwVjFXBpz28N5RAGN2zzCzEjD8NVYQJ2QyHj0gfWe',
|
||||||
'fakePassword',
|
'fakePassword',
|
||||||
);
|
);
|
||||||
assert.ok(!decrypted);
|
assert.ok(!decrypted);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('can decrypt cipher created by CryptoJS@3.1.9-1', () => {
|
it('can decrypt cipher created by CryptoJS@3.1.9-1 (legacy v1 path)', async () => {
|
||||||
const data2decrypt = 'really long data string bla bla really long data string bla bla really long data string bla bla';
|
const data2decrypt = 'really long data string bla bla really long data string bla bla really long data string bla bla';
|
||||||
const crypted =
|
const crypted =
|
||||||
'U2FsdGVkX19fJ4PcLum+tmBpEVNgGGsGKOhRS21cEcYAox+Df8VqmnnG9t2PvpM05eWImCRArorVUUegtcfSq314WMFzxKmiPIl9eqV1aOY+VFGuIBx0VIVsCWix2Q7sRZZwnOVpG5bdveZI0+Azyw==';
|
'U2FsdGVkX19fJ4PcLum+tmBpEVNgGGsGKOhRS21cEcYAox+Df8VqmnnG9t2PvpM05eWImCRArorVUUegtcfSq314WMFzxKmiPIl9eqV1aOY+VFGuIBx0VIVsCWix2Q7sRZZwnOVpG5bdveZI0+Azyw==';
|
||||||
const decrypted = c.decrypt(crypted, 'password');
|
const decrypted = await c.decrypt(crypted, 'password');
|
||||||
assert.deepEqual(data2decrypt, decrypted);
|
assert.deepEqual(data2decrypt, decrypted);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('can decrypt a ciphertext produced by the OpenSSL CLI (wire-format check)', () => {
|
it('can decrypt a ciphertext produced by the OpenSSL CLI (legacy v1 wire-format check)', async () => {
|
||||||
// Regenerate this fixture with (copy-pasteable, verified to reproduce the byte string below):
|
// Regenerate this fixture with (copy-pasteable, verified to reproduce the byte string below):
|
||||||
//
|
//
|
||||||
// { printf 'Salted__\x01\x02\x03\x04\x05\x06\x07\x08'; \
|
// { printf 'Salted__\x01\x02\x03\x04\x05\x06\x07\x08'; \
|
||||||
@ -56,6 +56,28 @@ describe('unit - encryption', function () {
|
|||||||
// passing `-S <hex>` suppresses the header, so we prepend it manually. Pins the
|
// passing `-S <hex>` suppresses the header, so we prepend it manually. Pins the
|
||||||
// on-disk format against an independent reference beyond crypto-js.
|
// on-disk format against an independent reference beyond crypto-js.
|
||||||
const crypted = 'U2FsdGVkX18BAgMEBQYHCMqtJuZaneiHrVN/oMPPLvFplovZbI1K+lulGJn7NAvn';
|
const crypted = 'U2FsdGVkX18BAgMEBQYHCMqtJuZaneiHrVN/oMPPLvFplovZbI1K+lulGJn7NAvn';
|
||||||
assert.strictEqual(c.decrypt(crypted, 'mypassword'), 'hello world this is plaintext');
|
assert.strictEqual(await c.decrypt(crypted, 'mypassword'), 'hello world this is plaintext');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('roundtrips multi-byte UTF-8 (emoji / CJK) under v2', async () => {
|
||||||
|
const data = '日本語テスト 🌅🔥🌊 multi-byte plaintext ✅';
|
||||||
|
const crypted = await c.encrypt(data, 'pässwörd中');
|
||||||
|
assert.ok(crypted.startsWith('v2:'));
|
||||||
|
const decrypted = await c.decrypt(crypted, 'pässwörd中');
|
||||||
|
assert.strictEqual(decrypted, data);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false on tampered v2 ciphertext (AEAD auth-tag mismatch)', async () => {
|
||||||
|
const crypted = await c.encrypt('legitimate payload bytes here', 'password');
|
||||||
|
assert.ok(crypted.startsWith('v2:'));
|
||||||
|
// Flip one base64 char near the end (inside the auth tag region) — must not decrypt
|
||||||
|
const tampered = `${crypted.slice(0, -2)}${crypted.slice(-2) === 'AA' ? 'BB' : 'AA'}`;
|
||||||
|
assert.strictEqual(await c.decrypt(tampered, 'password'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false on empty / non-string input', async () => {
|
||||||
|
assert.strictEqual(await c.decrypt('', 'password'), false);
|
||||||
|
// @ts-expect-error — runtime guard for non-string input
|
||||||
|
assert.strictEqual(await c.decrypt(undefined, 'password'), false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -212,12 +212,16 @@ describe('LNURL', function () {
|
|||||||
it('decipherAES returns empty string on malformed input (preserves crypto-js contract)', () => {
|
it('decipherAES returns empty string on malformed input (preserves crypto-js contract)', () => {
|
||||||
const preimage = 'bf62911aa53c017c27ba34391f694bc8bf8aaf59b4ebfd9020e66ac0412e189b';
|
const preimage = 'bf62911aa53c017c27ba34391f694bc8bf8aaf59b4ebfd9020e66ac0412e189b';
|
||||||
const validIv = 'eTGduB45hWTOxHj1dR+LJw==';
|
const validIv = 'eTGduB45hWTOxHj1dR+LJw==';
|
||||||
// Non-block-aligned ciphertext — would throw under raw @noble/ciphers
|
// Valid base64 that decodes to 13 bytes — not block-aligned for AES
|
||||||
assert.strictEqual(Lnurl.decipherAES('not-base64-aligned', preimage, validIv), '');
|
assert.strictEqual(Lnurl.decipherAES('not-base64-aligned', preimage, validIv), '');
|
||||||
// Bad PKCS7 padding (random 16-byte block won't unpad cleanly)
|
// Bad PKCS7 padding (random 16-byte block won't unpad cleanly)
|
||||||
assert.strictEqual(Lnurl.decipherAES('AAAAAAAAAAAAAAAAAAAAAA==', preimage, validIv), '');
|
assert.strictEqual(Lnurl.decipherAES('AAAAAAAAAAAAAAAAAAAAAA==', preimage, validIv), '');
|
||||||
// Empty ciphertext
|
// Empty ciphertext
|
||||||
assert.strictEqual(Lnurl.decipherAES('', preimage, validIv), '');
|
assert.strictEqual(Lnurl.decipherAES('', preimage, validIv), '');
|
||||||
|
// Valid AES + valid PKCS7 padding but plaintext is invalid UTF-8 (0xff 0xfe 0xfd 0xfc + PKCS7).
|
||||||
|
// Without a fatal UTF-8 decode the call would return mojibake; we want '' here so the
|
||||||
|
// success screen renders a blank line instead of garbage characters.
|
||||||
|
assert.strictEqual(Lnurl.decipherAES('AdX3qMNfEqZLW65Z8xk/fQ==', preimage, validIv), '');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -525,3 +525,70 @@ it('Appstorage - hashIt() works', async () => {
|
|||||||
const storage = new BlueApp();
|
const storage = new BlueApp();
|
||||||
assert.strictEqual(storage.hashIt('hello'), '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824');
|
assert.strictEqual(storage.hashIt('hello'), '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('Appstorage - lazy v1 → v2 upgrade rewrites legacy bucket on successful decrypt (opt-in)', async () => {
|
||||||
|
// Legacy CryptoJS@3.1.9-1 ciphertext under password 'password' — same fixture
|
||||||
|
// used in tests/unit/encryption.test.ts.
|
||||||
|
const legacyV1 =
|
||||||
|
'U2FsdGVkX19fJ4PcLum+tmBpEVNgGGsGKOhRS21cEcYAox+Df8VqmnnG9t2PvpM05eWImCRArorVUUegtcfSq314WMFzxKmiPIl9eqV1aOY+VFGuIBx0VIVsCWix2Q7sRZZwnOVpG5bdveZI0+Azyw==';
|
||||||
|
const expectedPlaintext = 'really long data string bla bla really long data string bla bla really long data string bla bla';
|
||||||
|
|
||||||
|
await AsyncStorage.setItem('data', JSON.stringify([legacyV1]));
|
||||||
|
await AsyncStorage.setItem(BlueApp.FLAG_ENCRYPTED, '1');
|
||||||
|
|
||||||
|
const Storage = new BlueApp();
|
||||||
|
const decrypted = await Storage.decryptData(JSON.stringify([legacyV1]), 'password', { upgrade: true });
|
||||||
|
assert.strictEqual(decrypted, expectedPlaintext);
|
||||||
|
|
||||||
|
// On-disk bucket should have been rewritten as v2 by the lazy upgrade.
|
||||||
|
const rewritten = JSON.parse(await AsyncStorage.getItem('data'));
|
||||||
|
assert.strictEqual(rewritten.length, 1);
|
||||||
|
assert.ok(rewritten[0].startsWith('v2:'), `expected v2: prefix after upgrade, got: ${rewritten[0].slice(0, 16)}…`);
|
||||||
|
|
||||||
|
// Sanity: the new v2 ciphertext still decrypts to the same plaintext.
|
||||||
|
const Storage2 = new BlueApp();
|
||||||
|
const reread = await Storage2.decryptData(await AsyncStorage.getItem('data'), 'password');
|
||||||
|
assert.strictEqual(reread, expectedPlaintext);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Appstorage - decryptData does NOT rewrite bucket without the upgrade opt-in (default read-only behaviour)', async () => {
|
||||||
|
const legacyV1 =
|
||||||
|
'U2FsdGVkX19fJ4PcLum+tmBpEVNgGGsGKOhRS21cEcYAox+Df8VqmnnG9t2PvpM05eWImCRArorVUUegtcfSq314WMFzxKmiPIl9eqV1aOY+VFGuIBx0VIVsCWix2Q7sRZZwnOVpG5bdveZI0+Azyw==';
|
||||||
|
const onDisk = JSON.stringify([legacyV1]);
|
||||||
|
await AsyncStorage.setItem('data', onDisk);
|
||||||
|
await AsyncStorage.setItem(BlueApp.FLAG_ENCRYPTED, '1');
|
||||||
|
|
||||||
|
// No opts → no side-effect. Critical for isPasswordInUse (PD probe path).
|
||||||
|
const Storage = new BlueApp();
|
||||||
|
const decrypted = await Storage.decryptData(onDisk, 'password');
|
||||||
|
assert.ok(decrypted);
|
||||||
|
|
||||||
|
// On-disk state must be byte-exact unchanged.
|
||||||
|
assert.strictEqual(await AsyncStorage.getItem('data'), onDisk);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Appstorage - lazy v1 → v2 upgrade leaves untouched buckets at v1 (loop skips non-matching bucket)', async () => {
|
||||||
|
// Decoy bucket FIRST (different password — decryptV1 returns false on it),
|
||||||
|
// real bucket SECOND. Exercises the loop continuation path where the
|
||||||
|
// upgrade has to skip a non-matching bucket and only upgrade the one
|
||||||
|
// whose password we know. Models the plausible-deniability scenario where
|
||||||
|
// decoy buckets the user does not unlock stay legacy.
|
||||||
|
// Decoy bucket: base64 that decodes to non-"Salted__" bytes — fails the magic
|
||||||
|
// check inside decryptV1, returns false, loop continues to the next bucket.
|
||||||
|
// Stands in for a bucket whose password the user did not supply this session.
|
||||||
|
const legacyV1Decoy = 'bm90LWEtdjEtY2lwaGVydGV4dC1qdXN0LXNvbWUtcmFuZG9tLWJ5dGVz';
|
||||||
|
const legacyV1Real =
|
||||||
|
'U2FsdGVkX19fJ4PcLum+tmBpEVNgGGsGKOhRS21cEcYAox+Df8VqmnnG9t2PvpM05eWImCRArorVUUegtcfSq314WMFzxKmiPIl9eqV1aOY+VFGuIBx0VIVsCWix2Q7sRZZwnOVpG5bdveZI0+Azyw==';
|
||||||
|
|
||||||
|
await AsyncStorage.setItem('data', JSON.stringify([legacyV1Decoy, legacyV1Real]));
|
||||||
|
await AsyncStorage.setItem(BlueApp.FLAG_ENCRYPTED, '1');
|
||||||
|
|
||||||
|
const Storage = new BlueApp();
|
||||||
|
const decrypted = await Storage.decryptData(JSON.stringify([legacyV1Decoy, legacyV1Real]), 'password', { upgrade: true });
|
||||||
|
assert.ok(decrypted);
|
||||||
|
|
||||||
|
const rewritten = JSON.parse(await AsyncStorage.getItem('data'));
|
||||||
|
assert.strictEqual(rewritten.length, 2);
|
||||||
|
assert.strictEqual(rewritten[0], legacyV1Decoy, 'decoy bucket must remain byte-exact unchanged');
|
||||||
|
assert.ok(rewritten[1].startsWith('v2:'), 'real bucket should be upgraded to v2');
|
||||||
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user