Compare commits

...

23 commits

Author SHA1 Message Date
Thebora Kompanioni
a1abd6c7e6
feat: add language selector to login page (#1431)
Some checks are pending
Build / build (v26.5.0) (push) Waiting to run
CodeQL / Analyze (push) Waiting to run
Deploy Storybook / deploy (push) Waiting to run
* chore: remove wrapper from language selector and move to ui module

* chore: rename SettingItem -> SettingsItem

* chore(settings): hover and pointer only for items with action

* feat: add language selector to login page
2026-08-13 11:53:27 +05:30
Anirudh Patwal
0b707f44fc
fix(a11y): make settings rows keyboard-accessible (#1430)
Some checks are pending
Build / build (v26.5.0) (push) Waiting to run
CodeQL / Analyze (push) Waiting to run
Deploy Storybook / deploy (push) Waiting to run
2026-08-12 10:55:54 +02:00
Aman Yadav
69423b6b1d
perf(context): optimize combinedUtxosHash from O(n^2) to O(n) (#1424) 2026-08-11 21:18:00 +02:00
Kishore B
d406a18456
feat: add orderbook availability pre-check for send and sweep (#1407) 2026-08-11 20:49:16 +02:00
Kishore B
047a8ab779
refactor(ui): consolidate Send and Sweep precondition alerts (#1402)
Signed-off-by: kishore08-07 <kishorebsm8@gmail.com>
2026-08-11 21:50:55 +05:30
Guflly
d9918b30b0
fix(earn): export complete report as CSV (#1426) 2026-08-11 17:49:57 +02:00
Parth
04c62c12fd
fix(fb): explain missing eligible UTXOs (#1408)
Some checks are pending
Build / build (v26.5.0) (push) Waiting to run
CodeQL / Analyze (push) Waiting to run
Deploy Storybook / deploy (push) Waiting to run
2026-08-11 09:53:42 +02:00
Parth
c832b80a2f
feat(earn): show own offer status in local orderbook (#1420) 2026-08-10 22:04:39 +02:00
theborakompanioni
1535a8148e
chore(build): fix lint warnings in form validation test
Some checks are pending
Build / build (v26.5.0) (push) Waiting to run
CodeQL / Analyze (push) Waiting to run
Deploy Storybook / deploy (push) Waiting to run
2026-08-09 22:58:11 +02:00
GuTS805
6022ed3d41
fix(a11y): make balance visibility toggle keyboard-accessible (#1412) 2026-08-09 21:05:49 +02:00
Aman Yadav
8f9d8d5917
fix(validation): correctly handle zero block height in blockHeightField (#1414) 2026-08-09 20:57:31 +02:00
Anirudh Patwal
56541c5c29
fix(fb): re-freeze fidelity bond utxo when renew/move sweep fails (#1399) 2026-08-09 20:47:48 +02:00
Kishore B
b9f2730a72
feat(wallet): implement wallet transaction history (#1368)
* feat(wallet): implement transaction history feature

- Add useQueryWalletHistory hook to fetch paginated transaction history from daemon
- Add TxHistoryTable component to render sortable and expandable transaction rows
- Add TxHistoryContent container for handling loading states and batch pagination
- Add TxHistoryOverlay full-screen dialog for viewing complete history
- Integrate inline Recent Activity section into MainWalletPage
- Add translation keys
- Add comprehensive unit tests

Signed-off-by: kishore08-07 <kishorebsm8@gmail.com>

* Update src/i18n/locales/en/translation.json

Co-authored-by: Thebora Kompanioni <theborakompanioni@users.noreply.github.com>

* feat(wallet): migrate transaction history to dedicated page and update dashboard view

- Move transaction history to top-level /wallet/history route and add sidebar link
- Refactor dashboard transaction history into a collapsible accordion section
- Auto-refetch transaction history on UTXO set hash () updates
- Fallback to raw string for unrecognized roles in
- Standardize i18n keys

Signed-off-by: kishore08-07 <kishorebsm8@gmail.com>

* style(wallet): update taker role badge variant to cj-change in TxHistoryTable

Signed-off-by: kishore08-07 <kishorebsm8@gmail.com>

* chore: hide tx history behind feature toggle

---------

Signed-off-by: kishore08-07 <kishorebsm8@gmail.com>
Co-authored-by: Thebora Kompanioni <theborakompanioni@users.noreply.github.com>
Co-authored-by: theborakompanioni <theborakompanioni+github@gmail.com>
2026-08-09 20:44:07 +02:00
theborakompanioni
037bf95784
chore(dev): expose orderbook watcher for dev container 2026-08-09 20:37:53 +02:00
Kishore B
152793a11b
ui(send): improve active collaborative send info (#1413)
* refactor(send): improve active collaborative send alert

Signed-off-by: kishore08-07 <kishorebsm8@gmail.com>

* chore: remove schedulerRunning flag from send alert

* chore(storybook): add ActiveCollaborativeSendAlert story

* chore(ui): ActiveCollaborativeSendAlert is an Item component

* chore(ui): blur forms when maker/taker operation is running

---------

Signed-off-by: kishore08-07 <kishorebsm8@gmail.com>
Co-authored-by: theborakompanioni <theborakompanioni+github@gmail.com>
2026-08-09 17:54:34 +02:00
Thebora Kompanioni
8eef1958a5
build(deps): update dependencies (#1416)
Some checks failed
Build / build (v26.5.0) (push) Has been cancelled
CodeQL / Analyze (push) Has been cancelled
Deploy Storybook / deploy (push) Has been cancelled
@hookform/resolvers            5.5.7  →    5.5.8
 @storybook/addon-a11y         10.5.5  →   10.5.7
 @storybook/addon-docs         10.5.5  →   10.5.7
 @storybook/addon-vitest       10.5.5  →   10.5.7
 @storybook/react-vite         10.5.5  →   10.5.7
 @testing-library/user-event   14.6.1  →   14.6.3
 @types/react                 19.2.17  →  19.2.18
 @types/react-dom              19.2.3  →   19.2.4
 conventional-changelog         8.1.0  →    8.1.1
 eslint-plugin-storybook       10.5.5  →   10.5.7
 storybook                     10.5.5  →   10.5.7
 vite                           8.2.0  →    8.2.1

 @chromatic-com/storybook   5.2.1  →   5.3.0
 @hookform/resolvers        5.5.8  →   5.7.1
 @noble/hashes              2.2.0  →   2.3.0
 globals                   17.8.0  →  17.9.0
 lint-staged               17.2.0  →  17.3.0
 lucide-react              1.28.0  →  1.30.0
 react-hook-form           7.83.0  →  7.84.0
 typescript-eslint         8.65.0  →  8.66.0
2026-08-07 23:26:18 +05:30
Kishore B
28888dbb6d
refactor: externalize hardcoded polling intervals and delays (#1406)
Some checks failed
Build / build (v26.5.0) (push) Has been cancelled
CodeQL / Analyze (push) Has been cancelled
Deploy Storybook / deploy (push) Has been cancelled
* refactor: externalize hardcoded polling intervals and delays

Signed-off-by: kishore08-07 <kishorebsm8@gmail.com>

* refactor(constants): move schedule polling interval dev config to env.dev

Signed-off-by: kishore08-07 <kishorebsm8@gmail.com>

---------

Signed-off-by: kishore08-07 <kishorebsm8@gmail.com>
2026-08-05 18:18:52 +05:30
Thebora Kompanioni
d0326db3e7
chore(ui): align buttons in logs and earn report (#1405)
Some checks are pending
Build / build (v26.5.0) (push) Waiting to run
CodeQL / Analyze (push) Waiting to run
Deploy Storybook / deploy (push) Waiting to run
* chore(report): align buttons

* chore: align search input and button rows in earn report and logs
2026-08-04 22:52:54 +05:30
GuTS805
9fb32af42f
fix: pin toLocaleString() calls to en-US locale (#1400)
* fix: pin toLocaleString() calls to en-US locale
Several number formatting call sites relied on the host machine's default
locale instead of a fixed one, causing inconsistent digit grouping in the
UI and non-deterministic test failures on non-US locales.

* fix: address review feedback, adapt tests instead of hardcoding locale in components
2026-08-04 22:32:48 +05:30
Thebora Kompanioni
4c2a886c6b
chore: print console warning (#1403)
Some checks are pending
Build / build (v26.5.0) (push) Waiting to run
CodeQL / Analyze (push) Waiting to run
Deploy Storybook / deploy (push) Waiting to run
2026-08-03 15:38:34 +05:30
Thebora Kompanioni
4a90f342ff
refactor: use specific address type in ambiguity check (#1404)
Some checks are pending
Build / build (v26.5.0) (push) Waiting to run
CodeQL / Analyze (push) Waiting to run
Deploy Storybook / deploy (push) Waiting to run
2026-08-03 01:03:04 +05:30
Thebora Kompanioni
c70a8892a6
chore(build): remove console.debug from prod build (#1401) 2026-08-03 01:00:28 +05:30
Ash
51a23b4c5a
fix(validation): distinguish testnet and regtest for bech32 addresses (#1395)
Some checks are pending
Build / build (v26.5.0) (push) Waiting to run
CodeQL / Analyze (push) Waiting to run
Deploy Storybook / deploy (push) Waiting to run
* fix(validation): distinguish testnet and regtest for bech32 addresses

Base58 addresses (p2pkh/p2sh) genuinely collapse between testnet and regtest
because they share version bytes, so bitcoin-address-validation always labels
them testnet - the interchangeable fallback is correct for those. Bech32
addresses, however, carry a distinct HRP (tb1 vs bcrt1) and are already
identified per-network, so gate the fallback on !bech32. This stops a bcrt1
(regtest) address passing on a testnet wallet, and vice versa.

Follow-up to #1355.

* docs(test): correct base58 address type in comment (P2PKH, not P2SH)

* test(validation): pin testnet/regtest separation for bech32m taproot addresses
2026-08-02 13:53:24 +05:30
86 changed files with 4602 additions and 1114 deletions

View file

@ -13,3 +13,5 @@ VITE_JAM_SWEEP_DESTINATION_ADDRESSES_MIN_COUNT=1
VITE_JAM_SWEEP_DESTINATION_ADDRESSES_DEFAULT_COUNT=3
VITE_JAM_SWEEP_MIN_MIN_NUMBER_OF_COLLABORATORS=1
VITE_JAM_RUNNING_SCHEDULE_POLLING_INTERVAL=5000

View file

@ -86,6 +86,8 @@ services:
NETWORK_CONFIG__NETWORK: testnet
NETWORK_CONFIG__BITCOIN_NETWORK: regtest
NETWORK_CONFIG__DIRECTORY_SERVERS: ${JM_ALL_DIRECTORY_NODES:?You must set the directory node addresses in generated env file}
ORDERBOOK_WATCHER__HTTP_HOST: joinmarket_ng_orderbook_watcher
ORDERBOOK_WATCHER__HTTP_PORT: 8000
OBWATCH_URL: http://joinmarket_ng_orderbook_watcher:8000
TOR__SOCKS_HOST: tor
TOR__SOCKS_PORT: 9050
@ -124,6 +126,8 @@ services:
NETWORK_CONFIG__NETWORK: testnet
NETWORK_CONFIG__BITCOIN_NETWORK: regtest
NETWORK_CONFIG__DIRECTORY_SERVERS: ${JM_ALL_DIRECTORY_NODES:?You must set the directory node addresses in generated env file}
ORDERBOOK_WATCHER__HTTP_HOST: joinmarket_ng_orderbook_watcher
ORDERBOOK_WATCHER__HTTP_PORT: 8000
OBWATCH_URL: http://joinmarket_ng_orderbook_watcher:8000
TOR__SOCKS_HOST: tor
TOR__SOCKS_PORT: 9050

View file

@ -156,6 +156,8 @@ services:
TOR__SOCKS_HOST: tor
TOR__SOCKS_PORT: 9050
LOGGING__LEVEL: DEBUG
ORDERBOOK_WATCHER__HTTP_HOST: 0.0.0.0
ORDERBOOK_WATCHER__HTTP_PORT: 8000
expose:
- 8000
ports:

View file

@ -26,7 +26,7 @@ If your separately running joinmarket-ng services use different ports, you can o
JAM_BACKEND=joinmarket-ng \
JMWALLETD_API_PORT=28183 \
JMWALLETD_WEBSOCKET_PORT=28283 \
JMOBWATCH_PORT=8080 \
JMOBWATCH_PORT=8000 \
npm run dev
```

429
package-lock.json generated
View file

@ -9,9 +9,9 @@
"version": "2.0.0-beta.2",
"license": "MIT",
"dependencies": {
"@hookform/resolvers": "5.5.7",
"@hookform/resolvers": "5.7.1",
"@joinmarket-webui/joinmarket-ng-api-ts": "1.0.0",
"@noble/hashes": "2.2.0",
"@noble/hashes": "2.3.0",
"@radix-ui/react-accordion": "1.2.20",
"@radix-ui/react-avatar": "1.2.6",
"@radix-ui/react-checkbox": "1.3.11",
@ -38,13 +38,13 @@
"clsx": "2.1.1",
"html5-qrcode": "2.3.8",
"i18next-browser-languagedetector": "8.2.1",
"lucide-react": "1.28.0",
"lucide-react": "1.30.0",
"next-themes": "0.4.6",
"qrcode": "1.5.4",
"radix-ui": "1.6.7",
"react": "19.2.8",
"react-dom": "19.2.8",
"react-hook-form": "7.83.0",
"react-hook-form": "7.84.0",
"react-i18next": "17.0.11",
"react-router-dom": "7.18.2",
"react-use-websocket": "4.13.0",
@ -56,45 +56,45 @@
"zustand": "5.0.14"
},
"devDependencies": {
"@chromatic-com/storybook": "5.2.1",
"@chromatic-com/storybook": "5.3.0",
"@eslint/js": "9.39.5",
"@playwright/test": "1.62.0",
"@storybook/addon-a11y": "10.5.5",
"@storybook/addon-docs": "10.5.5",
"@storybook/addon-vitest": "10.5.5",
"@storybook/react-vite": "10.5.5",
"@storybook/addon-a11y": "10.5.7",
"@storybook/addon-docs": "10.5.7",
"@storybook/addon-vitest": "10.5.7",
"@storybook/react-vite": "10.5.7",
"@tanstack/eslint-plugin-query": "5.101.4",
"@testing-library/jest-dom": "7.0.0",
"@testing-library/react": "16.3.2",
"@testing-library/user-event": "14.6.1",
"@testing-library/user-event": "14.6.3",
"@trivago/prettier-plugin-sort-imports": "6.0.2",
"@types/node": "26.1.2",
"@types/qrcode": "1.5.6",
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"@types/react": "19.2.18",
"@types/react-dom": "19.2.4",
"@vitejs/plugin-react": "6.0.5",
"@vitest/browser": "4.1.10",
"@vitest/browser-playwright": "4.1.10",
"@vitest/coverage-v8": "4.1.10",
"conventional-changelog": "8.1.0",
"conventional-changelog": "8.1.1",
"eslint": "9.39.5",
"eslint-plugin-compat": "7.0.2",
"eslint-plugin-react-hooks": "7.1.1",
"eslint-plugin-react-refresh": "0.5.3",
"eslint-plugin-storybook": "10.5.5",
"eslint-plugin-storybook": "10.5.7",
"eslint-plugin-unicorn": "65.0.1",
"globals": "17.8.0",
"globals": "17.9.0",
"husky": "9.1.7",
"jsdom": "30.0.1",
"lint-staged": "17.2.0",
"lint-staged": "17.3.0",
"msw": "2.15.0",
"msw-storybook-addon": "3.0.0",
"prettier": "3.9.6",
"prettier-plugin-tailwindcss": "0.8.1",
"storybook": "10.5.5",
"storybook": "10.5.7",
"typescript": "6.0.3",
"typescript-eslint": "8.65.0",
"vite": "8.2.0",
"typescript-eslint": "8.66.0",
"vite": "8.2.1",
"vitest": "4.1.10"
},
"engines": {
@ -462,14 +462,14 @@
}
},
"node_modules/@chromatic-com/storybook": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/@chromatic-com/storybook/-/storybook-5.2.1.tgz",
"integrity": "sha512-z6I7NJk/0VngA64y5TNYaB4Hc2X8+90n4op6lBt9PvWk5TmIlFLDqdX33rlrwbNRkkYijVgA/wO04rVYXi5Mlg==",
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@chromatic-com/storybook/-/storybook-5.3.0.tgz",
"integrity": "sha512-gMAfVYJnF/tjdY8C3Q54KYz/low2NFjPIZMpCqSFuMUiTJ2DNWCTb7mFhuuzZ1iduWFDhewlJyilFmvKcEDYRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@neoconfetti/react": "^1.0.0",
"chromatic": "16.10.0",
"chromatic": "^18.0.1",
"jsonfile": "^6.1.0",
"strip-ansi": "^7.1.0"
},
@ -1378,9 +1378,9 @@
"license": "MIT"
},
"node_modules/@hookform/resolvers": {
"version": "5.5.7",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.5.7.tgz",
"integrity": "sha512-CyPCYV8/KlXfEXLWj8HHHhVsR/IZ6Ckm3b/a4fWtO/lRnRK1huqncb8LlAWrmpPRsse5glF5aVuDRMHEr3UGag==",
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.7.1.tgz",
"integrity": "sha512-8wS/P4UDr5sQDe4nFaV51TVyfDPrWgNIXweqG0Bs9Z5LSuzKLb+RQNPvkN2oHM5SRrJyWrVH/F+LOUcFjUyvwQ==",
"license": "MIT",
"dependencies": {
"@standard-schema/utils": "^0.3.0"
@ -1389,12 +1389,12 @@
"@sinclair/typebox": ">=0.25.24",
"@standard-schema/spec": "^1.0.0",
"@typeschema/main": ">=0.13.7",
"@vinejs/vine": "^2.0.0 || ^3.0.0",
"@vinejs/vine": "^2.0.0 || ^3.0.0 || ^4.0.0",
"ajv": "^8.12.0",
"ajv-errors": "^3.0.0",
"ajv-formats": "^2.1.1",
"arktype": "^2.0.0",
"ata-validator": "^0.7.0",
"ata-validator": "^1.2.0",
"class-transformer": ">=0.4.0",
"class-validator": ">=0.12.0",
"computed-types": "^1.0.0",
@ -1809,7 +1809,7 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"node_modules/@noble/curves/node_modules/@noble/hashes": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
@ -1821,6 +1821,18 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.3.0.tgz",
"integrity": "sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@open-draft/deferred-promise": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-3.0.0.tgz",
@ -4431,6 +4443,18 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip32/node_modules/@noble/hashes": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip39": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.2.0.tgz",
@ -4444,6 +4468,18 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip39/node_modules/@noble/hashes": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@simple-libs/child-process-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@simple-libs/child-process-utils/-/child-process-utils-2.0.0.tgz",
@ -4517,9 +4553,9 @@
"license": "MIT"
},
"node_modules/@storybook/addon-a11y": {
"version": "10.5.5",
"resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-10.5.5.tgz",
"integrity": "sha512-nsMnSRe7pzepXIkUUqI/rL7sp8juXOluyypU4Dz0UuYHhw/cKaxfzuO+3WJN5EtEr/gcnKYb4awxH/duPGavUw==",
"version": "10.5.7",
"resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-10.5.7.tgz",
"integrity": "sha512-I30rsNz6aA3xg3811MEry40uJDHP3l5SOkfqtmNkp7y4NdqTDdKhmbhhDAZQX1WWEk6GeMBGr3AJ3TCz7r7JmQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -4531,20 +4567,20 @@
"url": "https://opencollective.com/storybook"
},
"peerDependencies": {
"storybook": "^10.5.5"
"storybook": "^10.5.7"
}
},
"node_modules/@storybook/addon-docs": {
"version": "10.5.5",
"resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.5.5.tgz",
"integrity": "sha512-0YpKlimS4XE0kQ8Maa5coeefQxdyDrBHg1wOP3WTPuBe4FolFSCDveR0ge2+vuUBk+fZfn2+l+3Q2jmAWaRGDg==",
"version": "10.5.7",
"resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.5.7.tgz",
"integrity": "sha512-KNARJfjICaizinsR3INMEiipZm1ObYo+xw+E26gteu50Bcy2dIZUtk5uHY5XdtardU3AXX6yRXoBZ2HCY3lbHA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@mdx-js/react": "^3.0.0",
"@storybook/csf-plugin": "10.5.5",
"@storybook/csf-plugin": "10.5.7",
"@storybook/icons": "^2.0.2",
"@storybook/react-dom-shim": "10.5.5",
"@storybook/react-dom-shim": "10.5.7",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"ts-dedent": "^2.0.0"
@ -4555,7 +4591,7 @@
},
"peerDependencies": {
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "^10.5.5"
"storybook": "^10.5.7"
},
"peerDependenciesMeta": {
"@types/react": {
@ -4564,9 +4600,9 @@
}
},
"node_modules/@storybook/addon-vitest": {
"version": "10.5.5",
"resolved": "https://registry.npmjs.org/@storybook/addon-vitest/-/addon-vitest-10.5.5.tgz",
"integrity": "sha512-Ymq9ErkSkYiIDuqpJ2+hE5GCQ5J6TCLOWhutqArvwaeAO+HAibM82XNExpJ1/kvPqk9y961GDPkv2W15I88JIw==",
"version": "10.5.7",
"resolved": "https://registry.npmjs.org/@storybook/addon-vitest/-/addon-vitest-10.5.7.tgz",
"integrity": "sha512-7NK7Kzazc2vb2h8nGlH15QlUn5J6/LV0xF70VziAK0bxu3r1JupLCoBvckX39vHzFXiAZljXZtt1Wq/OidBxow==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -4581,7 +4617,7 @@
"@vitest/browser": "^3.0.0 || ^4.0.0",
"@vitest/browser-playwright": "^4.0.0",
"@vitest/runner": "^3.0.0 || ^4.0.0",
"storybook": "^10.5.5",
"storybook": "^10.5.7",
"vitest": "^3.0.0 || ^4.0.0"
},
"peerDependenciesMeta": {
@ -4600,13 +4636,13 @@
}
},
"node_modules/@storybook/builder-vite": {
"version": "10.5.5",
"resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.5.5.tgz",
"integrity": "sha512-dQoJ7gUl8y0z5rV9cE0mz6qTBNmN9R4GOLIZk98rJ8CwduNJOb9eGZXusDzzvnYcp8TnNkqDtyx4tXQSUDInPQ==",
"version": "10.5.7",
"resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.5.7.tgz",
"integrity": "sha512-fShF/aQaITqcJuMCLr42BGNUAbhDi4IboqvlbZqXAwgrrTslnZEUnY8GcEcvpZmjl11VwlmazhMJdH50fIgBPg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@storybook/csf-plugin": "10.5.5",
"@storybook/csf-plugin": "10.5.7",
"ts-dedent": "^2.0.0"
},
"funding": {
@ -4614,14 +4650,14 @@
"url": "https://opencollective.com/storybook"
},
"peerDependencies": {
"storybook": "^10.5.5",
"storybook": "^10.5.7",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/@storybook/csf-plugin": {
"version": "10.5.5",
"resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.5.5.tgz",
"integrity": "sha512-/euibhRFqklYCZqUseokojmfYcQpXshVY2QmA1qCuxMz9SzVFD3iSTw+aFLTxpsJGGdcZJk8fnm/rEthLzZ9jA==",
"version": "10.5.7",
"resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.5.7.tgz",
"integrity": "sha512-IaX8FlM0H36HNFhJ2+4L9bCldqfvHGqcLg841SJNyK/DhfMlM7JsvY/GDH2ZFuWrUf8FSOx96GRRnHq6XfRKag==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -4634,7 +4670,7 @@
"peerDependencies": {
"esbuild": "*",
"rollup": "*",
"storybook": "^10.5.5",
"storybook": "^10.5.7",
"vite": "*",
"webpack": "*"
},
@ -4671,14 +4707,14 @@
}
},
"node_modules/@storybook/react": {
"version": "10.5.5",
"resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.5.5.tgz",
"integrity": "sha512-T2Xj0ey7a9RHU6coYLC0L5lhjcdyhLCs9wNv15FvHvgmrRobkynEV72kq5vGW8tFkahNWI1X9+GZPQ6r8Nm38w==",
"version": "10.5.7",
"resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.5.7.tgz",
"integrity": "sha512-uFvty2MMdFXzW5PcQe1JqDAZkz6cQq7q/9G/cbGVnBEvP6zsOVeL+bmrQ0/WBlFQN0Ko9+ZoCTvaQ9s65zBa5g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@storybook/global": "^5.0.0",
"@storybook/react-dom-shim": "10.5.5",
"@storybook/react-dom-shim": "10.5.7",
"react-docgen": "^8.0.2",
"react-docgen-typescript": "^2.2.2"
},
@ -4691,7 +4727,7 @@
"@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "^10.5.5",
"storybook": "^10.5.7",
"typescript": ">= 4.9.x"
},
"peerDependenciesMeta": {
@ -4707,9 +4743,9 @@
}
},
"node_modules/@storybook/react-dom-shim": {
"version": "10.5.5",
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.5.tgz",
"integrity": "sha512-PIk7N3LLrZIxfNxmkvmQN1d5UQ70XEedT8n0GhBiXnM6XL09xPGB8n8TZXeJBRYluKhDQcAyQeT0/OZmcDVQJg==",
"version": "10.5.7",
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.7.tgz",
"integrity": "sha512-lxOkyh+wu/MiBXvYQHjZfD+DRKOa4bHBzbuGuiHXnHXmdOcTRdcrQTsoeN2FPtfugmmOG66cZUEgDwNX+k5eRA==",
"dev": true,
"license": "MIT",
"funding": {
@ -4721,7 +4757,7 @@
"@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "^10.5.5"
"storybook": "^10.5.7"
},
"peerDependenciesMeta": {
"@types/react": {
@ -4733,16 +4769,16 @@
}
},
"node_modules/@storybook/react-vite": {
"version": "10.5.5",
"resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-10.5.5.tgz",
"integrity": "sha512-Uy7VV72kVSkw6aDTAPQupXUeZX5LF6e4zqNvTZ+36qxsXAkaFgw7HPEm7L1tsaRfiV+s9anU7UvX47tfJpYGuQ==",
"version": "10.5.7",
"resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-10.5.7.tgz",
"integrity": "sha512-eEo3eVa2pvqrzQukKxAzx7YvswDAA1s6k/y+tdMxmRvWyHX6QEOsb9Tda6wcVaa7c8BeJM7Ggq+289cRMTH6Iw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@joshwooding/vite-plugin-react-docgen-typescript": "^0.7.0",
"@rollup/pluginutils": "^5.0.2",
"@storybook/builder-vite": "10.5.5",
"@storybook/react": "10.5.5",
"@storybook/builder-vite": "10.5.7",
"@storybook/react": "10.5.7",
"empathic": "^2.0.0",
"magic-string": "^0.30.0",
"react-docgen": "^8.0.2",
@ -4756,7 +4792,7 @@
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "^10.5.5",
"storybook": "^10.5.7",
"typescript": ">= 4.9.x",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
},
@ -5212,9 +5248,9 @@
}
},
"node_modules/@testing-library/user-event": {
"version": "14.6.1",
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
"integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==",
"version": "14.6.3",
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.3.tgz",
"integrity": "sha512-6dBq67jT8lE+JTE8Exm02Kt6ze43hz1jdiSpSJwtTZiT1xQQ6b7nZYTTQ9njdArdU8XklOwaDp/AbT/eYSKF4g==",
"dev": true,
"license": "MIT",
"engines": {
@ -5395,9 +5431,9 @@
}
},
"node_modules/@types/react": {
"version": "19.2.17",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
"version": "19.2.18",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
"devOptional": true,
"license": "MIT",
"dependencies": {
@ -5405,9 +5441,9 @@
}
},
"node_modules/@types/react-dom": {
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==",
"devOptional": true,
"license": "MIT",
"peerDependencies": {
@ -5439,17 +5475,17 @@
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz",
"integrity": "sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==",
"version": "8.66.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.66.0.tgz",
"integrity": "sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.65.0",
"@typescript-eslint/type-utils": "8.65.0",
"@typescript-eslint/utils": "8.65.0",
"@typescript-eslint/visitor-keys": "8.65.0",
"@typescript-eslint/scope-manager": "8.66.0",
"@typescript-eslint/type-utils": "8.66.0",
"@typescript-eslint/utils": "8.66.0",
"@typescript-eslint/visitor-keys": "8.66.0",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0"
@ -5462,7 +5498,7 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.65.0",
"@typescript-eslint/parser": "^8.66.0",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
@ -5478,16 +5514,16 @@
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.65.0.tgz",
"integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==",
"version": "8.66.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.66.0.tgz",
"integrity": "sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.65.0",
"@typescript-eslint/types": "8.65.0",
"@typescript-eslint/typescript-estree": "8.65.0",
"@typescript-eslint/visitor-keys": "8.65.0",
"@typescript-eslint/scope-manager": "8.66.0",
"@typescript-eslint/types": "8.66.0",
"@typescript-eslint/typescript-estree": "8.66.0",
"@typescript-eslint/visitor-keys": "8.66.0",
"debug": "^4.4.3"
},
"engines": {
@ -5503,14 +5539,14 @@
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz",
"integrity": "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==",
"version": "8.66.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.66.0.tgz",
"integrity": "sha512-7MthGPTt4BP69lSryqpqq8HQqxuzynssckL/jyDyk3+TNMQ3y2jFWkptCrktWvBrP+EH787Nl5N5Qpw7WZg+5g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.65.0",
"@typescript-eslint/types": "^8.65.0",
"@typescript-eslint/tsconfig-utils": "^8.66.0",
"@typescript-eslint/types": "^8.66.0",
"debug": "^4.4.3"
},
"engines": {
@ -5525,14 +5561,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz",
"integrity": "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==",
"version": "8.66.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.66.0.tgz",
"integrity": "sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.65.0",
"@typescript-eslint/visitor-keys": "8.65.0"
"@typescript-eslint/types": "8.66.0",
"@typescript-eslint/visitor-keys": "8.66.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -5543,9 +5579,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz",
"integrity": "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==",
"version": "8.66.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.66.0.tgz",
"integrity": "sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g==",
"dev": true,
"license": "MIT",
"engines": {
@ -5560,15 +5596,15 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.65.0.tgz",
"integrity": "sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==",
"version": "8.66.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.66.0.tgz",
"integrity": "sha512-LG2dWfjZQQp0ADtAu/EWJVayefGL2UEZ3CDeI44D9v3rXB/WYUqE/jpO28KrEKul5AySrmI+Zh1v6v+xW2U9+g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.65.0",
"@typescript-eslint/typescript-estree": "8.65.0",
"@typescript-eslint/utils": "8.65.0",
"@typescript-eslint/types": "8.66.0",
"@typescript-eslint/typescript-estree": "8.66.0",
"@typescript-eslint/utils": "8.66.0",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
},
@ -5585,9 +5621,9 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz",
"integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==",
"version": "8.66.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.66.0.tgz",
"integrity": "sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ==",
"dev": true,
"license": "MIT",
"engines": {
@ -5599,16 +5635,16 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz",
"integrity": "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==",
"version": "8.66.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.66.0.tgz",
"integrity": "sha512-8/x4INiiQb10jGgXYD7116/zQ+OL84ZIFn0za68wwFHCanT/VLbBEroWht8RV8fn0/ZCAoazHLQgwUC0UQcDfg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.65.0",
"@typescript-eslint/tsconfig-utils": "8.65.0",
"@typescript-eslint/types": "8.65.0",
"@typescript-eslint/visitor-keys": "8.65.0",
"@typescript-eslint/project-service": "8.66.0",
"@typescript-eslint/tsconfig-utils": "8.66.0",
"@typescript-eslint/types": "8.66.0",
"@typescript-eslint/visitor-keys": "8.66.0",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
@ -5637,9 +5673,9 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -5650,13 +5686,13 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
"integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.5"
"brace-expansion": "^5.0.8"
},
"engines": {
"node": "18 || 20 || >=22"
@ -5666,16 +5702,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz",
"integrity": "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==",
"version": "8.66.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.66.0.tgz",
"integrity": "sha512-jasearZPolBw5NJNYGMwxzHMF83niVWmMU1VdHzG1CyfI2VS7f7nZltnKtHcg20hW+7Uo5GfK4MeDPoU3qI8EA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.65.0",
"@typescript-eslint/types": "8.65.0",
"@typescript-eslint/typescript-estree": "8.65.0"
"@typescript-eslint/scope-manager": "8.66.0",
"@typescript-eslint/types": "8.66.0",
"@typescript-eslint/typescript-estree": "8.66.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -5690,13 +5726,13 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz",
"integrity": "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==",
"version": "8.66.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.66.0.tgz",
"integrity": "sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.65.0",
"@typescript-eslint/types": "8.66.0",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
@ -6394,9 +6430,9 @@
}
},
"node_modules/chromatic": {
"version": "16.10.0",
"resolved": "https://registry.npmjs.org/chromatic/-/chromatic-16.10.0.tgz",
"integrity": "sha512-nFsztmnu7rFiGafUJgXvLUNpqmRylz92eNvzBoJNTKKQj4EQUyxznwnfpf1dTs7hXtWD8JwcH92jADydaHA1sw==",
"version": "18.1.0",
"resolved": "https://registry.npmjs.org/chromatic/-/chromatic-18.1.0.tgz",
"integrity": "sha512-I9av8lUc5CQRlYzwKpmOG9cwYvZ4AFmTvtHeNrzOMC1353F9xYw45CHpSNIsNKuOiqqmzci9esXQd5akl0fi6w==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -6407,6 +6443,9 @@
"chromatic": "dist/bin.cjs",
"chromatic-cli": "dist/bin.cjs"
},
"engines": {
"node": ">=22.0.0"
},
"peerDependencies": {
"@chromatic-com/cypress": "^0.*.* || ^1.0.0",
"@chromatic-com/playwright": "^0.*.* || ^1.0.0",
@ -6568,9 +6607,9 @@
"license": "MIT"
},
"node_modules/conventional-changelog": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/conventional-changelog/-/conventional-changelog-8.1.0.tgz",
"integrity": "sha512-idt1JK+3+Nt7gqH/d/sEYWdDeR+5XPov/jssmFN6XxmYSRlonTWSDWhWUPkmm0zbwYjtVdt+4My5aiPkKyvocw==",
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/conventional-changelog/-/conventional-changelog-8.1.1.tgz",
"integrity": "sha512-my0arqBpzr8mg+7RKDGnw8OiDvBr8/QVJIxazYJlIDeKx/2GCBYxZELaDYpbRhM5gNKfZB8h9SHuetrUFxd5HQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -6580,7 +6619,7 @@
"argue-cli": "^3.1.0",
"conventional-changelog-preset-loader": "^6.0.1",
"conventional-changelog-writer": "^9.2.0",
"conventional-commits-parser": "^7.1.0",
"conventional-commits-parser": "^7.1.2",
"fd-package-json": "^2.0.0"
},
"bin": {
@ -6631,9 +6670,9 @@
}
},
"node_modules/conventional-commits-parser": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-7.1.0.tgz",
"integrity": "sha512-DPp6hkUjvwIivxbkrTiLXeRswNv1A/4GFA2X6scXma0AMa9632V3TwxmrlkUIEtUktiM3Ln+RrSH2xlP3/jUTw==",
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-7.1.2.tgz",
"integrity": "sha512-O+x4N2yH+ijvqWlIyTHsXTAP+algNWgGbjY2duCe8w2vUMvUB95cLRslCPfTMQyLAKlet3bhZTdu6ozn4M+QJQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -7153,9 +7192,9 @@
}
},
"node_modules/eslint-plugin-storybook": {
"version": "10.5.5",
"resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.5.5.tgz",
"integrity": "sha512-xGCrddoZ8pMmwc7M5mp+habWbvqBjRuyGVTJ6RpavpoAP6NBUfGSRXHuBs7cHRDpDSuErYeDiEo4XHTonRp68g==",
"version": "10.5.7",
"resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.5.7.tgz",
"integrity": "sha512-mLpamG1Rsica2jYbUzIZOEuy7Fm1IMtVLMvvxGTpjTVKUMxTXJsANx3MBpH2VSbGQB8Yzlt5399WL/O07K97Ig==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -7164,7 +7203,7 @@
},
"peerDependencies": {
"eslint": ">=8",
"storybook": "^10.5.5"
"storybook": "^10.5.7"
}
},
"node_modules/eslint-plugin-unicorn": {
@ -7633,9 +7672,9 @@
}
},
"node_modules/glob/node_modules/brace-expansion": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -7662,9 +7701,9 @@
}
},
"node_modules/globals": {
"version": "17.8.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-17.8.0.tgz",
"integrity": "sha512-Zz/LMDZScFmkakeL2cTHzf+PbWKdpU3uclqkZT7TjDG58j5WPt0PpA+n9uPI24fZtlw07q0OtEi84K+umsRzqQ==",
"version": "17.9.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-17.9.0.tgz",
"integrity": "sha512-m/MvAW61QVU5VDNF1Vj8axt016h8w7L5TU1e9zlab7XIttAT2YAlCwl75K1fOqvMM9apmD7lbCIRhpfkhmxhCg==",
"dev": true,
"license": "MIT",
"engines": {
@ -8070,9 +8109,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"dev": true,
"funding": [
{
@ -8504,9 +8543,9 @@
}
},
"node_modules/lint-staged": {
"version": "17.2.0",
"resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.2.0.tgz",
"integrity": "sha512-FchGnFe4i4B1C/a35SPU9bNGPEHSC1+1iV0plLjzBmKVe9klZrlRfSgK6Cw4VeHyqOXbJUXP0vON61uRftNQ0A==",
"version": "17.3.0",
"resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.3.0.tgz",
"integrity": "sha512-woZS3vNe3UKqBaLPvbLOtKRY4tLANpWQhom12MGWqC8Mh1lCOO+WgSwmX2amjJAqTY9BkXYW87fCUH5H9Ph6xw==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -8582,9 +8621,9 @@
}
},
"node_modules/lucide-react": {
"version": "1.28.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.28.0.tgz",
"integrity": "sha512-fARAFJULsGuDDydjp6+6blekG/sBIM29TerzLjc9bQUKAcEfrSc4ZQKb25KRz4OMKd87cZTb5dgq0w/T6KufVg==",
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.30.0.tgz",
"integrity": "sha512-tUIr2jXLbWpCkdtH8XP7P7YppM9ueWgTky99lpWDY6z5REs6B+O6ZQ3U5tHkUUY59ANyOv/PBcs8E4Fe3KO3eA==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
@ -8777,9 +8816,9 @@
}
},
"node_modules/nanoid": {
"version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"version": "3.3.17",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz",
"integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==",
"funding": [
{
"type": "github",
@ -9180,9 +9219,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.23",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
"integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
"version": "8.5.26",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
"funding": [
{
"type": "opencollective",
@ -9199,7 +9238,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.16",
"nanoid": "^3.3.17",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@ -9666,9 +9705,9 @@
}
},
"node_modules/react-hook-form": {
"version": "7.83.0",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.83.0.tgz",
"integrity": "sha512-AXt8cMCmx5a7u4uvpb2uRFVrWQhllI4pV+LSykxIac/hjt44TnQkmX9BKuQi2i+LDC62esmiLpilkav+kjVf/A==",
"version": "7.84.0",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.84.0.tgz",
"integrity": "sha512-+hWvQP6GLco56mDwrbU4XnHix8t1z90ltZsDIrREl+jnQFQxYLX8oAzqe/Xn8nHpmoXTY5M6oEXrAhbP1qevNQ==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
@ -9835,9 +9874,9 @@
"license": "MIT"
},
"node_modules/recast": {
"version": "0.23.12",
"resolved": "https://registry.npmjs.org/recast/-/recast-0.23.12.tgz",
"integrity": "sha512-dEWRjcINDu/F4l2dYx57ugBtD7HV9KXESyxhzw/MqWLeglJrsjJKqACPyUPg+6AF8mIgm+Zi0dZ3ACoIg+QtpA==",
"version": "0.23.19",
"resolved": "https://registry.npmjs.org/recast/-/recast-0.23.19.tgz",
"integrity": "sha512-T98lym7kH+pnZmRaD8yDRdaNqyUbwnbEBx0MuchrzMFOEMray4AO3ZJoTUZ5r78Ao78X/OhzW0DL8GB85w/I2w==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -10189,16 +10228,16 @@
"license": "MIT"
},
"node_modules/storybook": {
"version": "10.5.5",
"resolved": "https://registry.npmjs.org/storybook/-/storybook-10.5.5.tgz",
"integrity": "sha512-UscBIBJDloUeqntukHOhP1a5W/vouePDJbzPSxj466WK801FZtzQiMffMtkjzJiWSuj20wfaYlB2QQKh9aOYAg==",
"version": "10.5.7",
"resolved": "https://registry.npmjs.org/storybook/-/storybook-10.5.7.tgz",
"integrity": "sha512-oiKvWIwIoOhFP1i6dASYyMXwPHKEtVZMshqSB7EvIVYjWRh0l9H7gHEt1z4Gh2rLGFMekWdsm4s94rvwpR7gkg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@storybook/global": "^5.0.0",
"@storybook/icons": "^2.0.2",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/jest-dom": "6.9.1",
"@testing-library/user-event": "^14.6.1",
"@vitest/expect": "3.2.4",
"@vitest/spy": "3.2.4",
@ -10659,16 +10698,16 @@
}
},
"node_modules/typescript-eslint": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.65.0.tgz",
"integrity": "sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==",
"version": "8.66.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.66.0.tgz",
"integrity": "sha512-QlEbBPz/RuJ1XUHj29nm3t0F/O/cSlEnntozqPOYHnnTGAXFamnMBu5i9Vn6vhUPHGAjR+Vl+5J8vPN/BMUrJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.65.0",
"@typescript-eslint/parser": "8.65.0",
"@typescript-eslint/typescript-estree": "8.65.0",
"@typescript-eslint/utils": "8.65.0"
"@typescript-eslint/eslint-plugin": "8.66.0",
"@typescript-eslint/parser": "8.66.0",
"@typescript-eslint/typescript-estree": "8.66.0",
"@typescript-eslint/utils": "8.66.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -10829,15 +10868,15 @@
}
},
"node_modules/vite": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz",
"integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==",
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz",
"integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==",
"license": "MIT",
"dependencies": {
"lightningcss": "^1.33.0",
"picomatch": "^4.0.5",
"postcss": "^8.5.23",
"rolldown": "~1.2.0",
"postcss": "^8.5.25",
"rolldown": "~1.2.1",
"tinyglobby": "^0.2.17"
},
"bin": {

View file

@ -71,9 +71,9 @@
]
},
"dependencies": {
"@hookform/resolvers": "5.5.7",
"@hookform/resolvers": "5.7.1",
"@joinmarket-webui/joinmarket-ng-api-ts": "1.0.0",
"@noble/hashes": "2.2.0",
"@noble/hashes": "2.3.0",
"@radix-ui/react-accordion": "1.2.20",
"@radix-ui/react-avatar": "1.2.6",
"@radix-ui/react-checkbox": "1.3.11",
@ -100,13 +100,13 @@
"clsx": "2.1.1",
"html5-qrcode": "2.3.8",
"i18next-browser-languagedetector": "8.2.1",
"lucide-react": "1.28.0",
"lucide-react": "1.30.0",
"next-themes": "0.4.6",
"qrcode": "1.5.4",
"radix-ui": "1.6.7",
"react": "19.2.8",
"react-dom": "19.2.8",
"react-hook-form": "7.83.0",
"react-hook-form": "7.84.0",
"react-i18next": "17.0.11",
"react-router-dom": "7.18.2",
"react-use-websocket": "4.13.0",
@ -118,45 +118,45 @@
"zustand": "5.0.14"
},
"devDependencies": {
"@chromatic-com/storybook": "5.2.1",
"@chromatic-com/storybook": "5.3.0",
"@eslint/js": "9.39.5",
"@playwright/test": "1.62.0",
"@storybook/addon-a11y": "10.5.5",
"@storybook/addon-docs": "10.5.5",
"@storybook/addon-vitest": "10.5.5",
"@storybook/react-vite": "10.5.5",
"@storybook/addon-a11y": "10.5.7",
"@storybook/addon-docs": "10.5.7",
"@storybook/addon-vitest": "10.5.7",
"@storybook/react-vite": "10.5.7",
"@tanstack/eslint-plugin-query": "5.101.4",
"@testing-library/jest-dom": "7.0.0",
"@testing-library/react": "16.3.2",
"@testing-library/user-event": "14.6.1",
"@testing-library/user-event": "14.6.3",
"@trivago/prettier-plugin-sort-imports": "6.0.2",
"@types/node": "26.1.2",
"@types/qrcode": "1.5.6",
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"@types/react": "19.2.18",
"@types/react-dom": "19.2.4",
"@vitejs/plugin-react": "6.0.5",
"@vitest/browser": "4.1.10",
"@vitest/browser-playwright": "4.1.10",
"@vitest/coverage-v8": "4.1.10",
"conventional-changelog": "8.1.0",
"conventional-changelog": "8.1.1",
"eslint": "9.39.5",
"eslint-plugin-compat": "7.0.2",
"eslint-plugin-react-hooks": "7.1.1",
"eslint-plugin-react-refresh": "0.5.3",
"eslint-plugin-storybook": "10.5.5",
"eslint-plugin-storybook": "10.5.7",
"eslint-plugin-unicorn": "65.0.1",
"globals": "17.8.0",
"globals": "17.9.0",
"husky": "9.1.7",
"jsdom": "30.0.1",
"lint-staged": "17.2.0",
"lint-staged": "17.3.0",
"msw": "2.15.0",
"msw-storybook-addon": "3.0.0",
"prettier": "3.9.6",
"prettier-plugin-tailwindcss": "0.8.1",
"storybook": "10.5.5",
"storybook": "10.5.7",
"typescript": "6.0.3",
"typescript-eslint": "8.65.0",
"vite": "8.2.0",
"typescript-eslint": "8.66.0",
"vite": "8.2.1",
"vitest": "4.1.10"
},
"overrides": {

View file

@ -48,6 +48,7 @@ import { EarnReportPage } from './components/earn/report/EarnReportPage'
import { RootLayout } from './components/layout/RootLayout'
import { LockWalletConfirmDialog } from './components/ui/jam/LockWalletConfirmDialog'
import { Spinner } from './components/ui/spinner'
import { TxHistoryPage } from './components/wallet/TxHistoryPage'
import { WalletJarsDetailsPage } from './components/wallet/WalletJarsDetailsPage'
import { useJamSessionInfoContext } from './context/JamSessionInfoContext'
import { JamSessionInfoContextProvider } from './context/JamSessionInfoContextProvider'
@ -240,6 +241,7 @@ function App() {
path={routes.walletJarsDetails}
element={<WalletJarsDetailsPage walletFileName={walletFileName!} />}
/>
<Route path={routes.txHistory} element={<TxHistoryPage walletFileName={walletFileName!} />} />
{isDeveloperMode && isDebugFeatureEnabled('devPage') && (
<Route
id="dev-page"

View file

@ -32,6 +32,12 @@ vi.mock('@/context/JamWalletInfoContext', () => ({
useJars: () => ({ jars }),
}))
vi.mock('@/store/jamSettingsStore', () => ({
usePreviewFeatures: () => ({
'tx-history': true,
}),
}))
vi.mock('./wallet/WalletJarsDetailsOverlay', () => ({
WalletJarsDetailsOverlay: ({
open,
@ -53,6 +59,10 @@ vi.mock('./wallet/WalletJarsDetailsOverlay', () => ({
),
}))
vi.mock('./wallet/TxHistoryContent', () => ({
TxHistoryContent: () => <div data-testid="TxHistoryContent" />,
}))
vi.mock('@/components/ui/jam/Balance', () => ({
Balance: ({ valueString, onClick }: { valueString: string; onClick?: () => void }) => (
<span onClick={onClick}>{valueString}</span>
@ -142,4 +152,17 @@ describe('MainWalletPage', () => {
fireEvent.click(screen.getByText('5000'))
expect(toggleDisplayMode).toHaveBeenCalled()
})
it('expands and collapses transaction history via accordion toggle button', () => {
render(<MainWalletPage walletFileName={walletFileName} />)
expect(screen.queryByTestId('TxHistoryContent')).not.toBeInTheDocument()
const toggleButton = screen.getByTitle('tx_history.expand_history')
fireEvent.click(toggleButton)
expect(screen.getByTestId('TxHistoryContent')).toBeInTheDocument()
const collapseButton = screen.getByTitle('tx_history.collapse_history')
fireEvent.click(collapseButton)
expect(screen.queryByTestId('TxHistoryContent')).not.toBeInTheDocument()
})
})

View file

@ -1,5 +1,5 @@
import { useState } from 'react'
import { DownloadIcon, InfoIcon, RefreshCwIcon, UploadIcon } from 'lucide-react'
import { ChevronDownIcon, DownloadIcon, InfoIcon, RefreshCwIcon, UploadIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import { Alert, AlertDescription } from '@/components/ui/alert'
@ -17,8 +17,11 @@ import {
import { getErrorReason } from '@/lib/errorReason'
import type { WalletFileName } from '@/lib/utils'
import { cn, shortenStringMiddle, walletDisplayName } from '@/lib/utils'
import { usePreviewFeatures } from '@/store/jamSettingsStore'
import { Balance } from './ui/jam/Balance'
import { Spinner } from './ui/spinner'
import { TxHistoryContent } from './wallet/TxHistoryContent'
import { TxHistoryOverlay } from './wallet/TxHistoryOverlay'
import { WalletJarsDetailsOverlay } from './wallet/WalletJarsDetailsOverlay'
interface MainWalletPageProps {
@ -28,8 +31,11 @@ interface MainWalletPageProps {
export default function MainWalletPage({ walletFileName }: MainWalletPageProps) {
const { t } = useTranslation()
const navigate = useNavigate()
const previewFeatures = usePreviewFeatures()
const [selectedJar, setSelectedJar] = useState<JarObject>()
const [isWalletJarsDetailsOpen, setIsWalletJarsDetailsOpen] = useState(false)
const [isTxHistoryOpen, setIsTxHistoryOpen] = useState(false)
const [isHistoryExpanded, setIsHistoryExpanded] = useState(false)
const { toggleDisplayMode } = useJamDisplayContext()
const { isLoading, isFetching, error, refetch: refetchWalletData } = useJamWalletInfoContext()
@ -55,6 +61,10 @@ export default function MainWalletPage({ walletFileName }: MainWalletPageProps)
walletFileName={walletFileName}
selectedJarIndex={selectedJar?.jarIndex}
/>
{previewFeatures?.['tx-history'] === true ? (
<TxHistoryOverlay open={isTxHistoryOpen} onOpenChange={setIsTxHistoryOpen} walletFileName={walletFileName} />
) : null}
<div className="flex flex-col items-center justify-center gap-8 px-4 py-12">
<div className="flex w-full max-w-xl flex-col items-center justify-center gap-2">
<p className="text-muted-foreground hover:text-foreground text-xl select-all" title={walletName}>
@ -87,8 +97,37 @@ export default function MainWalletPage({ walletFileName }: MainWalletPageProps)
{t('current_wallet.button_withdraw')}
</Button>
</div>
{previewFeatures?.['tx-history'] === true ? (
<div className="my-6 flex w-full max-w-4xl items-center gap-3">
<div className="bg-border h-px flex-1" />
<Button
type="button"
variant="outline"
size="icon"
className="border-border bg-background size-8 shrink-0 rounded-full"
onClick={() => setIsHistoryExpanded((previous) => !previous)}
title={isHistoryExpanded ? t('tx_history.collapse_history') : t('tx_history.expand_history')}
>
<ChevronDownIcon
className={cn('size-4 transition-transform duration-200', isHistoryExpanded && 'rotate-180')}
/>
</Button>
<div className="bg-border h-px flex-1" />
</div>
) : null}
</div>
{isHistoryExpanded && (
<TxHistoryContent
walletFileName={walletFileName}
compact={true}
initialLimit={5}
onViewAll={() => setIsTxHistoryOpen(true)}
className="w-full max-w-6xl"
/>
)}
{error ? (
<Alert variant="destructive" className="mb-4 max-w-xl">
<AlertDescription>

View file

@ -110,10 +110,8 @@ export function useCreateFidelityBondWizard(
const jarsWithUtxos = useMemo(() => {
return walletInfo.jars.filter((jar) => {
// TODO: let's allow selecting frozen utxos - unfreeze them before sending the transaction
const availableUtxos = jar.utxos.filter(
(utxo) =>
!utxo.frozen && !fb.utxo.isFidelityBond(utxo) && walletInfo.addressSummary[utxo.address]?.status === 'cj-out',
const availableUtxos = jar.utxos.filter((utxo) =>
fb.utxo.isEligibleForCreation(utxo, walletInfo.addressSummary[utxo.address]?.status),
)
return availableUtxos.length > 0
})
@ -121,17 +119,9 @@ export function useCreateFidelityBondWizard(
const availableUtxos = useMemo(() => {
if (selectedJar === undefined) return []
return (
selectedJar.utxos
// TODO: let's allow selecting frozen utxos - unfreeze them before sending the transaction
.filter(
(utxo) =>
!utxo.frozen &&
!fb.utxo.isFidelityBond(utxo) &&
walletInfo.addressSummary[utxo.address]?.status === 'cj-out',
)
.toSorted((a, b) => b.value - a.value)
)
return selectedJar.utxos
.filter((utxo) => fb.utxo.isEligibleForCreation(utxo, walletInfo.addressSummary[utxo.address]?.status))
.toSorted((a, b) => b.value - a.value)
}, [selectedJar, walletInfo.addressSummary])
const selectedUtxos = useMemo(() => {

View file

@ -2,7 +2,9 @@ import type React from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { FidelityBondUtxo } from '@/hooks/useQueryUtxos'
import * as JAM from '@/constants/jam'
import type { Jar } from '@/context/JamWalletInfoContext'
import type { FidelityBondUtxo, Utxo } from '@/hooks/useQueryUtxos'
import { jmSessionStore } from '@/store/jmSessionStore'
import type { EarnFormValues } from './EarnForm'
import { EarnPage } from './EarnPage'
@ -10,6 +12,12 @@ import { EarnPage } from './EarnPage'
const mocks = vi.hoisted(() => ({
developerMode: false,
feeConfigMissing: false,
orderbookData: vi.fn<() => unknown>(),
orderbookQueryOptions: vi.fn(),
orderbookQueryState: {
isError: false,
isLoading: false,
},
scrollToTop: vi.fn(),
startMaker: vi.fn(),
startMutationState: {
@ -26,17 +34,12 @@ const mocks = vi.hoisted(() => ({
toastInfo: vi.fn(),
toastSuccess: vi.fn(),
walletInfo: {
addressSummary: {},
fidelityBondSummary: { fbOutputs: [] as FidelityBondUtxo[] },
hasEligibleFidelityBondUtxo: true,
isFetching: false,
isLoading: false,
jars: [] as Array<{
balanceSummary: {
calculatedAvailableBalanceInSats: number
calculatedConfirmedAvailableBalanceInSats: number
calculatedFrozenOrLockedBalanceInSats: number
calculatedTotalBalanceInSats: number
}
}>,
jars: [] as Jar[],
maxJarAvailableBalance: 100_000_000,
},
}))
@ -76,9 +79,21 @@ vi.mock('@tanstack/react-query', () => ({
reset: vi.fn(),
}
}),
useQuery: vi.fn(() => ({
refetch: mocks.stopMakerRefetch,
})),
useQuery: vi.fn(() => {
return { refetch: mocks.stopMakerRefetch }
}),
}))
vi.mock('@/hooks/useQueryOrderbook', () => ({
useQueryOrderbook: (options: unknown) => {
mocks.orderbookQueryOptions(options)
return {
queryResult: {
data: mocks.orderbookData(),
...mocks.orderbookQueryState,
},
}
},
}))
vi.mock('react-i18next', () => ({
@ -194,9 +209,24 @@ vi.mock('./MoveToJarDialog', () => ({
}))
vi.mock('./OfferCard', () => ({
OfferCard: ({ children, nickname }: { children?: React.ReactNode; nickname?: string }) => (
OfferCard: ({
children,
nickname,
orderbookStatus,
orderbookOffer,
fidelityBond,
}: {
children?: React.ReactNode
nickname?: string
orderbookStatus?: string
orderbookOffer?: { fidelity_bond_value?: number }
fidelityBond?: { amount?: number }
}) => (
<div>
offer-card:{nickname}
<span>orderbook-status:{orderbookStatus}</span>
<span>bond-value:{orderbookOffer?.fidelity_bond_value}</span>
<span>bond-amount:{fidelityBond?.amount}</span>
{children}
</div>
),
@ -212,11 +242,35 @@ vi.mock('./report/EarnReportOverlay', () => ({
const balanceSummary = {
calculatedAvailableBalanceInSats: 100_000_000,
calculatedAvailableFrozenBalanceInSats: 0,
calculatedConfirmedAvailableBalanceInSats: 100_000_000,
calculatedFrozenOrLockedBalanceInSats: 0,
calculatedTotalBalanceInSats: 100_000_000,
}
const eligibleUtxo: Utxo = {
address: 'bcrt1qcj',
confirmations: 12,
external: false,
frozen: false,
label: '',
locktime: undefined,
mixdepth: 0,
path: "m/84'/1'/0'/0/0",
tries: 3,
tries_remaining: 3,
utxo: 'eligible:0',
value: 100_000,
}
const makeJar = (balanceOverrides = {}): Jar => ({
balanceSummary: { ...balanceSummary, ...balanceOverrides },
color: '#e2b86a',
jarIndex: 0,
name: 'Jar 0',
utxos: [eligibleUtxo],
})
const expiredBond: FidelityBondUtxo = {
address: 'bc1qbond',
confirmations: 12,
@ -251,6 +305,11 @@ describe('EarnPage', () => {
beforeEach(() => {
mocks.developerMode = false
mocks.feeConfigMissing = false
mocks.orderbookData.mockReset()
mocks.orderbookData.mockReturnValue(undefined)
mocks.orderbookQueryOptions.mockReset()
mocks.orderbookQueryState.isError = false
mocks.orderbookQueryState.isLoading = false
mocks.scrollToTop.mockReset()
mocks.startMaker.mockReset()
mocks.startMaker.mockResolvedValue({})
@ -264,10 +323,14 @@ describe('EarnPage', () => {
mocks.toastError.mockReset()
mocks.toastInfo.mockReset()
mocks.toastSuccess.mockReset()
mocks.walletInfo.addressSummary = {
[eligibleUtxo.address]: { status: 'cj-out' },
}
mocks.walletInfo.fidelityBondSummary = { fbOutputs: [] }
mocks.walletInfo.hasEligibleFidelityBondUtxo = true
mocks.walletInfo.isFetching = false
mocks.walletInfo.isLoading = false
mocks.walletInfo.jars = [{ balanceSummary }]
mocks.walletInfo.jars = [makeJar()]
mocks.walletInfo.maxJarAvailableBalance = 100_000_000
setSession()
})
@ -342,6 +405,51 @@ describe('EarnPage', () => {
expect(mocks.stopMakerRefetch).toHaveBeenCalledWith({ throwOnError: true })
})
it('shows the current offer and fidelity bond from the local orderbook', () => {
setSession({
maker_running: true,
offer_list: [{ oid: 7, cjfee: '250', minsize: '5000', ordertype: 'sw0absoffer' }],
})
mocks.orderbookData.mockReturnValue({
offers: [{ counterparty: 'maker-a', oid: 7, fidelity_bond_value: 42_000 }],
fidelitybonds: [
{ counterparty: 'maker-a', amount: 50_000, locktime: 1_700_000_000 },
{ counterparty: 'maker-a', amount: 100_000, locktime: 1_800_000_000 },
],
})
render(<EarnPage walletFileName="wallet.jmdat" />)
expect(screen.getByText('orderbook-status:visible')).toBeInTheDocument()
expect(screen.getByText('bond-value:42000')).toBeInTheDocument()
expect(screen.getByText('bond-amount:100000')).toBeInTheDocument()
})
it('polls until the current offer is visible, then slows down', () => {
setSession({
maker_running: true,
offer_list: [{ oid: 7, cjfee: '250', minsize: '5000', ordertype: 'sw0absoffer' }],
})
render(<EarnPage walletFileName="wallet.jmdat" />)
const { refetchInterval } = mocks.orderbookQueryOptions.mock.calls[0][0] as {
refetchInterval: (query: {
state: {
data?: { offers: Array<{ counterparty: string; oid: number }> }
error?: Error | null
}
}) => number
}
const visibleData = { offers: [{ counterparty: 'maker-a', oid: 7 }] }
expect(refetchInterval({ state: {} })).toBe(JAM.WAIT_FOR_UPDATE_ORDERBOOK_POLLING_INTERVAL)
expect(refetchInterval({ state: { data: visibleData } })).toBe(JAM.VISIBLE_ORDERBOOK_POLLING_INTERVAL)
expect(refetchInterval({ state: { data: visibleData, error: new Error('offline') } })).toBe(
JAM.WAIT_FOR_UPDATE_ORDERBOOK_POLLING_INTERVAL,
)
})
it('shows waiting states while maker updates', () => {
mocks.startMutationState.isSuccess = true
@ -377,6 +485,24 @@ describe('EarnPage', () => {
expect(screen.getByText('create-bond-dialog:true')).toBeInTheDocument()
})
it('explains when no UTXO is eligible for fidelity-bond creation', async () => {
const user = userEvent.setup()
mocks.walletInfo.hasEligibleFidelityBondUtxo = false
render(<EarnPage walletFileName="wallet.jmdat" />)
expect(screen.getByText('earn.fidelity_bond.create_form.alert_no_eligible_utxos_title')).toBeInTheDocument()
expect(screen.getByText('earn.fidelity_bond.create_form.alert_no_eligible_utxos_description')).toBeInTheDocument()
const createFidelityBondButton = screen.getByRole('button', {
name: 'earn.fidelity_bond.create_form.button_create',
})
expect(createFidelityBondButton).toBeDisabled()
await user.click(createFidelityBondButton)
expect(screen.getByText('create-bond-dialog:false')).toBeInTheDocument()
})
it('disables creating a fidelity bond while rescanning', async () => {
const user = userEvent.setup()
setSession({
@ -485,7 +611,7 @@ describe('EarnPage', () => {
})
it('warns when the spendable balance is only unconfirmed', () => {
mocks.walletInfo.jars = [{ balanceSummary: { ...balanceSummary, calculatedConfirmedAvailableBalanceInSats: 0 } }]
mocks.walletInfo.jars = [makeJar({ calculatedConfirmedAvailableBalanceInSats: 0 })]
mocks.walletInfo.maxJarAvailableBalance = 100_000_000
render(<EarnPage walletFileName="wallet.jmdat" />)
@ -497,7 +623,7 @@ describe('EarnPage', () => {
})
it('warns when there is no spendable balance at all', () => {
mocks.walletInfo.jars = [{ balanceSummary: { ...balanceSummary, calculatedConfirmedAvailableBalanceInSats: 0 } }]
mocks.walletInfo.jars = [makeJar({ calculatedConfirmedAvailableBalanceInSats: 0 })]
mocks.walletInfo.maxJarAvailableBalance = 0
render(<EarnPage walletFileName="wallet.jmdat" />)

View file

@ -2,14 +2,15 @@ import { useEffect, useMemo, useState } from 'react'
import { startmakerMutation, stopmakerOptions } from '@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query'
import type { StartMakerRequest } from '@joinmarket-webui/joinmarket-ng-api-ts/jm'
import { useMutation, useQuery } from '@tanstack/react-query'
import { FileTextIcon, HourglassIcon, PlusIcon, RefreshCwIcon, ShuffleIcon, UnlockIcon } from 'lucide-react'
import { FileTextIcon, HourglassIcon, InfoIcon, PlusIcon, RefreshCwIcon, ShuffleIcon, UnlockIcon } from 'lucide-react'
import type { SubmitHandler } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { Trans, useTranslation } from 'react-i18next'
import { Link } from 'react-router-dom'
import { toast } from 'sonner'
import { useStore } from 'zustand'
import { DevBadge } from '@/components/dev/DevBadge'
import { FeeConfigDialog } from '@/components/settings/fees/FeeConfigDialog'
import { Alert, AlertTitle } from '@/components/ui/alert'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { FeeConfigErrorAlert } from '@/components/ui/jam/FeeConfigErrorAlert'
@ -17,11 +18,14 @@ import { PageLoading } from '@/components/ui/jam/PageLoading'
import PageTitle from '@/components/ui/jam/PageTitle'
import { isDevMode } from '@/constants/debugFeatures'
import * as JAM from '@/constants/jam'
import { routes } from '@/constants/routes'
import { useJamWalletInfoContext } from '@/context/JamWalletInfoContext'
import { useApiClient } from '@/hooks/useApiClient'
import { useFeeConfigValidation } from '@/hooks/useFeeConfigValidation'
import { useQueryOrderbook } from '@/hooks/useQueryOrderbook'
import type { FidelityBondUtxo } from '@/hooks/useQueryUtxos'
import { useRefreshSession } from '@/hooks/useRefreshSession'
import * as OrderbookApi from '@/lib/api/orderbook'
import { getErrorReason } from '@/lib/errorReason'
import * as fb from '@/lib/fidelityBondUtils'
import { withQueryDelay } from '@/lib/queryClient'
@ -29,7 +33,6 @@ import { cn, isAbsoluteOffer, isRelativeOffer, percentageToFactor, scrollToTop }
import type { WalletFileName } from '@/lib/utils'
import { useDeveloperMode } from '@/store/jamSettingsStore'
import { jmSessionStore } from '@/store/jmSessionStore'
import type { Milliseconds } from '@/types/global'
import { Spinner } from '../ui/spinner'
import { CreateFidelityBondDialog } from './CreateFidelityBondDialog'
import { EarnForm, type EarnFormValues } from './EarnForm'
@ -40,15 +43,6 @@ import { OfferCard } from './OfferCard'
import { RenewBondDialog } from './RenewBondDialog'
import { EarnReportOverlay } from './report/EarnReportOverlay'
// In order to prevent state mismatch, the 'maker stop' response is delayed shortly.
// Even though the API response suggests that the maker has started or stopped immediately, it seems that this is not always the case.
// There is currently no way to know for sure - adding a delay at least mitigates the problem.
// 2022-04-26: With value of 2_000ms, no state corruption could be provoked in a local dev setup.
const MAKER_STOP_RESPONSE_DELAY: Milliseconds = 2_000
const WAIT_FOR_UPDATE_SESSION_POLLING_INTERVAL: Milliseconds = 3_000
const WAIT_FOR_UPDATE_SESSION_POLLING_DELAY: Milliseconds = 1_000
const toStartMakerRequest = (values: EarnFormValues): StartMakerRequest => {
// both fee properties need to be provided.
// prevent providing an invalid value by setting the ignored prop to zero
@ -90,6 +84,34 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
const [showFeeConfigDialog, setShowFeeConfigDialog] = useState(false)
const isCurrentOfferAvailable = jmSession?.offer_list && jmSession.offer_list.length > 0
const currentOffer = jmSession?.offer_list?.[0]
const isCurrentOrderbookOffer = (offer: OrderbookApi.OrderbookOffer) =>
offer.counterparty === jmSession?.nickname && String(offer.oid) === String(currentOffer?.oid)
const {
queryResult: { data: orderbookData, isFetching: orderbookIsFetching, isError: orderbookIsError },
} = useQueryOrderbook({
enabled: makerRunning && !!currentOffer && !!jmSession?.nickname,
staleTime: Number.POSITIVE_INFINITY,
refetchInterval: (query) =>
query.state.error || !query.state.data?.offers.some((offer) => isCurrentOrderbookOffer(offer))
? JAM.WAIT_FOR_UPDATE_ORDERBOOK_POLLING_INTERVAL
: JAM.VISIBLE_ORDERBOOK_POLLING_INTERVAL,
})
const currentOrderbookOffer = orderbookData?.offers.find((offer) => isCurrentOrderbookOffer(offer))
const currentOrderbookFidelityBond =
Number(currentOrderbookOffer?.fidelity_bond_value) > 0
? orderbookData?.fidelitybonds?.findLast((bond) => bond.counterparty === jmSession?.nickname)
: undefined
const orderbookStatus =
!jmSession?.nickname || orderbookIsFetching
? 'checking'
: orderbookIsError
? 'error'
: currentOrderbookOffer
? 'visible'
: 'missing'
const stopMakerQueryOptions = stopmakerOptions({
client,
@ -99,7 +121,7 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
const stopMakerQuery = useQuery({
...stopMakerQueryOptions,
queryFn: withQueryDelay(stopMakerQueryOptions.queryFn, {
delayAfter: MAKER_STOP_RESPONSE_DELAY,
delayAfter: JAM.MAKER_STOP_RESPONSE_DELAY,
}),
enabled: false,
retry: false,
@ -149,7 +171,9 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
!walletInfo.isFetching
const isCreateFidelityBondEnabled =
isFidelityBondActionsEnabled && (!hasFidelityBond || JAM_EARN_CREATE_MULTIPLE_FIDELITY_BONDS_ENABLED)
isFidelityBondActionsEnabled &&
walletInfo.hasEligibleFidelityBondUtxo &&
(!hasFidelityBond || JAM_EARN_CREATE_MULTIPLE_FIDELITY_BONDS_ENABLED)
const showCreateAdditionalFidelityBond = JAM_EARN_CREATE_MULTIPLE_FIDELITY_BONDS_ENABLED
const numberOfNonFrozenFidelityBondOutputs = !hasFidelityBond
@ -167,8 +191,8 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
useRefreshSession({
enabled: waitingForMakerUpdate || waitingForOfferUpdate,
refetchInterval: WAIT_FOR_UPDATE_SESSION_POLLING_INTERVAL,
refetchDelay: WAIT_FOR_UPDATE_SESSION_POLLING_DELAY,
refetchInterval: JAM.WAIT_FOR_UPDATE_SESSION_POLLING_INTERVAL,
refetchDelay: JAM.WAIT_FOR_UPDATE_SESSION_POLLING_DELAY,
})
const onStop = async () => {
@ -271,6 +295,9 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
className="motion-safe:animate-in blur-in"
value={jmSession.offer_list[0]}
nickname={jmSession.nickname}
orderbookStatus={orderbookStatus}
orderbookOffer={currentOrderbookOffer}
fidelityBond={currentOrderbookFidelityBond}
>
<Button type="button" onClick={() => void onStop()} className="w-full" size="lg">
{isWaitingMakerStop ? (
@ -289,7 +316,12 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
<Card
className={cn({
hidden: jmSession.maker_running && !waitingForOfferUpdate,
'blur-[2px]': isWaitingMakerStop || waitingForOfferUpdate,
'blur-[2px]':
isWaitingMakerStop ||
waitingForOfferUpdate ||
jmSession.maker_running ||
jmSession.coinjoin_in_process ||
jmSession.rescanning,
})}
>
<CardContent>
@ -329,6 +361,23 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
<CardDescription>{t('earn.fidelity_bond.subtitle')}</CardDescription>
<CardAction></CardAction>
</CardHeader>
{!walletInfo.hasEligibleFidelityBondUtxo && (
<CardContent>
<Alert variant="default">
<InfoIcon />
<AlertTitle>{t('earn.fidelity_bond.create_form.alert_no_eligible_utxos_title')}</AlertTitle>
<AlertDescription>
<Trans
i18nKey="earn.fidelity_bond.create_form.alert_no_eligible_utxos_description"
components={{
'1': <Link to={routes.send} className="font-semibold" />,
'3': <Link to={routes.walletJarsDetails} className="font-semibold" />,
}}
/>
</AlertDescription>
</Alert>
</CardContent>
)}
<CardFooter className="gap-2">
<Button
variant="default"

View file

@ -1,6 +1,7 @@
import type { SessionResponse } from '@joinmarket-webui/joinmarket-ng-api-ts/jm'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { withRuntimeLocale } from '@/test/withRuntimeLocale'
import { OfferCard } from './OfferCard'
type Offer = NonNullable<SessionResponse['offer_list']>[number]
@ -79,4 +80,41 @@ describe('OfferCard', () => {
expect(screen.getByTestId('child')).toBeInTheDocument()
})
it('shows whether the offer is visible in the local orderbook', () => {
const { rerender } = render(<OfferCard value={baseOffer} nickname="JMBot" orderbookStatus="visible" />)
expect(screen.getByText('earn.current.text_orderbook_visible')).toBeInTheDocument()
rerender(<OfferCard value={baseOffer} nickname="JMBot" orderbookStatus="missing" />)
expect(screen.getByText('earn.current.text_orderbook_missing')).toBeInTheDocument()
})
it('shows the advertised fidelity bond details', () => {
withRuntimeLocale('de-DE', () => {
render(
<OfferCard
value={baseOffer}
nickname="JMBot"
orderbookStatus="visible"
orderbookOffer={{
counterparty: 'JMBot',
oid: 123,
ordertype: 'sw0absoffer',
minsize: 10_000,
maxsize: 50_000,
txfee: 500,
cjfee: 1_000,
fidelity_bond_value: 42_000,
}}
fidelityBond={{ counterparty: 'JMBot', amount: 100_000, locktime: 1_800_000_000 }}
/>,
)
expect(screen.getByText('earn.current.text_fidelity_bond')).toBeInTheDocument()
expect(screen.getByText('earn.current.text_bond_value: 42.000')).toBeInTheDocument()
expect(screen.getByText('100000')).toBeInTheDocument()
expect(screen.getByText(/earn\.current\.text_bond_locktime/u)).toBeInTheDocument()
})
})
})

View file

@ -1,11 +1,22 @@
import type { PropsWithChildren } from 'react'
import type { SessionResponse } from '@joinmarket-webui/joinmarket-ng-api-ts/jm'
import type { TFunction } from 'i18next'
import { FingerprintIcon, HandCoinsIcon, Maximize2Icon, Minimize2Icon } from 'lucide-react'
import {
CircleAlertIcon,
CircleCheckIcon,
FingerprintIcon,
HandCoinsIcon,
Maximize2Icon,
Minimize2Icon,
PickaxeIcon,
SearchIcon,
ShieldCheckIcon,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardAction, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import type { OrderbookFidelityBond, OrderbookOffer } from '@/lib/api/orderbook'
import { cn, factorToPercentage, isAbsoluteOffer, isRelativeOffer } from '@/lib/utils'
import { Balance } from '../ui/jam/Balance'
import { Label } from '../ui/label'
@ -36,16 +47,50 @@ interface OfferCardProps {
className?: string
value: Offer
nickname: SessionResponse['nickname']
orderbookStatus?: 'checking' | 'visible' | 'missing' | 'error'
orderbookOffer?: OrderbookOffer
fidelityBond?: OrderbookFidelityBond
}
export function OfferCard({ className, value, nickname, children }: PropsWithChildren<OfferCardProps>) {
const orderbookStatusEntry = (value: OfferCardProps['orderbookStatus'], t: TFunction) => {
return value === 'visible'
? { icon: CircleCheckIcon, text: t('earn.current.text_orderbook_visible'), variant: 'success' as const }
: value === 'missing'
? { icon: SearchIcon, text: t('earn.current.text_orderbook_missing'), variant: 'warning' as const }
: value === 'error'
? { icon: CircleAlertIcon, text: t('earn.current.text_orderbook_error'), variant: 'destructive' as const }
: value === 'checking'
? { icon: SearchIcon, text: t('earn.current.text_orderbook_checking'), variant: 'muted' as const }
: undefined
}
export function OfferCard({
className,
value,
nickname,
orderbookStatus,
orderbookOffer,
fidelityBond,
children,
}: PropsWithChildren<OfferCardProps>) {
const { t } = useTranslation()
const bondValue = Number(orderbookOffer?.fidelity_bond_value) || 0
const offerInOrderbookStatus = orderbookStatusEntry(orderbookStatus, t)
const bondWarning = bondValue === 0 || orderbookOffer?.fidelity_bond_verification_stale === true
return (
<Card className={cn('transition-all duration-300 hover:-translate-y-[2px] hover:shadow-md', className)}>
<CardHeader>
<CardTitle>{t('earn.current.text_offer')}</CardTitle>
<CardDescription></CardDescription>
<CardDescription>
{offerInOrderbookStatus && (
<Badge className="whitespace-normal" variant={offerInOrderbookStatus.variant}>
<offerInOrderbookStatus.icon className="shrink-0" />
{offerInOrderbookStatus.text}
</Badge>
)}
</CardDescription>
<CardAction>
<Tooltip>
<TooltipTrigger asChild>
@ -100,8 +145,9 @@ export function OfferCard({ className, value, nickname, children }: PropsWithChi
</div>
</div>
{!!value?.txfee && (
<div className="flex items-center gap-4">
<div className="flex flex-col">
<div className="flex min-w-0 items-start gap-4">
<PickaxeIcon className="mt-0.5 shrink-0" />
<div className="min-w-0 flex-1">
<Label className="font-semibold">{t('earn.current.text_txfee')}</Label>
<span className="text-muted-foreground text-sm">
<Balance valueString={String(value?.txfee || '0')} />
@ -109,6 +155,45 @@ export function OfferCard({ className, value, nickname, children }: PropsWithChi
</div>
</div>
)}
{fidelityBond !== undefined && (
<div className="flex min-w-0 items-start gap-4 sm:col-span-full">
<ShieldCheckIcon
className={cn('mt-0.5 shrink-0', {
'text-brand-success': bondValue > 0,
'text-brand-warning': bondWarning,
})}
/>
<div className="min-w-0 flex-1">
<Label
className={cn('font-semibold', {
'text-brand-success': bondValue > 0,
'text-brand-warning': bondWarning,
})}
>
{t('earn.current.text_fidelity_bond')}
</Label>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-1 flex-col items-start gap-2 sm:flex-row sm:items-center">
<span className={bondValue <= 0 ? 'text-muted-foreground' : undefined}>
{t('earn.current.text_bond_value')}: {Math.floor(bondValue).toLocaleString()}
</span>
</div>
<div className="text-muted-foreground">
<Balance valueString={String(fidelityBond.amount)} />
<span className="text-muted-foreground">
{t('earn.current.text_bond_locktime', {
date: new Date(fidelityBond.locktime * 1_000).toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
}),
})}
</span>
</div>
</div>
</div>
</div>
)}
</CardContent>
<CardFooter className="flex-col gap-2">{children}</CardFooter>
</Card>

View file

@ -0,0 +1,208 @@
import { renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Jar } from '@/context/JamWalletInfoContext'
import type { FidelityBondUtxo, Utxo } from '@/hooks/useQueryUtxos'
import { BALANCE_SUMMARY_EMPTY } from '@/lib/balanceSummary'
import { useFidelityBondSweep } from './useFidelityBondSweep'
const mocks = vi.hoisted(() => ({
freezeMutateAsync: vi.fn(),
unfreezeMutateAsync: vi.fn(),
directSendMutateAsync: vi.fn(),
setError: vi.fn(),
walletInfoRefetch: vi.fn(),
walletInfo: {
jars: [] as Jar[],
refetch: vi.fn(),
},
}))
// `useFidelityBondSweep` only talks to the network through these two hooks —
// mock at that boundary and let the hook's own orchestration logic run for real.
vi.mock('@tanstack/react-query', () => ({
useMutation: vi.fn((options: { mutationFn: (variables: unknown) => Promise<unknown> }) => ({
mutateAsync: options.mutationFn,
isPending: false,
})),
}))
vi.mock('./useFidelityBondMutations', () => ({
useFidelityBondMutations: () => ({
freezeUtxo: { mutateAsync: mocks.freezeMutateAsync },
unfreezeUtxo: { mutateAsync: mocks.unfreezeMutateAsync },
directSend: { mutateAsync: mocks.directSendMutateAsync },
error: undefined,
setError: mocks.setError,
}),
}))
vi.mock('@/context/JamWalletInfoContext', async (importOriginal) => ({
...(await importOriginal<typeof import('@/context/JamWalletInfoContext')>()),
useJamWalletInfoContext: () => mocks.walletInfo,
}))
vi.mock('@/lib/utils', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/utils')>()),
delayedPromise: vi.fn(() => Promise.resolve()),
}))
const utxo = (overrides: Partial<Utxo>): Utxo => ({
utxo: 'tx:0',
address: 'bcrt1qsource',
path: "m/84'/1'/0'/0/0",
label: '',
value: 100_000,
tries: 0,
tries_remaining: 3,
external: false,
mixdepth: 0,
confirmations: 6,
frozen: false,
locktime: undefined,
...overrides,
})
const bondUtxo = (overrides: Partial<FidelityBondUtxo>): FidelityBondUtxo => ({
...utxo({ utxo: 'bond:0', frozen: true, ...overrides }),
locktime: '2999-01-01 00:00:00',
})
const jar = (jarIndex: number, utxos: Utxo[]): Jar =>
({
jarIndex,
name: `Jar ${jarIndex}`,
color: '#808080',
balanceSummary: BALANCE_SUMMARY_EMPTY,
utxos,
}) as unknown as Jar
describe('useFidelityBondSweep', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.freezeMutateAsync.mockResolvedValue(undefined)
mocks.unfreezeMutateAsync.mockResolvedValue(undefined)
mocks.walletInfoRefetch.mockResolvedValue(undefined)
mocks.walletInfo.refetch = mocks.walletInfoRefetch
})
it('re-freezes the fidelity bond after a failed sweep that had unfrozen it', async () => {
const bond = bondUtxo({ mixdepth: 0 })
const other = utxo({ utxo: 'other:0', mixdepth: 0, frozen: false })
mocks.walletInfo.jars = [jar(0, [bond, other])]
mocks.directSendMutateAsync.mockRejectedValue(new Error('broadcast failed'))
const { result } = renderHook(() =>
useFidelityBondSweep({
walletFileName: 'wallet.jmdat',
utxo: bond,
unfreezeErrorKey: 'earn.fidelity_bond.error_unfreezing_utxos',
sendErrorKey: 'earn.fidelity_bond.renew.error_renewing_fidelity_bond',
}),
)
const txResult = await result.current.sweep({
destination: 'bcrt1qdestination',
tryFreezeAfterBroadcast: false,
})
expect(txResult).toBeUndefined()
// the other jar utxo was frozen then correctly rolled back
expect(mocks.freezeMutateAsync).toHaveBeenCalledWith({
path: { walletname: 'wallet.jmdat' },
body: { 'utxo-string': 'other:0', freeze: true },
})
expect(mocks.unfreezeMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({ body: { 'utxo-string': 'other:0', freeze: false } }),
)
// the bond itself was unfrozen before the failed send, and MUST be re-frozen in rollback
expect(mocks.unfreezeMutateAsync).toHaveBeenCalledWith({
path: { walletname: 'wallet.jmdat' },
body: { 'utxo-string': 'bond:0', freeze: false },
})
expect(mocks.freezeMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({ body: { 'utxo-string': 'bond:0', freeze: true } }),
)
})
it('does not attempt to re-freeze the bond on failure when it was never unfrozen', async () => {
const bond = bondUtxo({ mixdepth: 0, frozen: false })
mocks.walletInfo.jars = [jar(0, [bond])]
mocks.directSendMutateAsync.mockRejectedValue(new Error('broadcast failed'))
const { result } = renderHook(() =>
useFidelityBondSweep({
walletFileName: 'wallet.jmdat',
utxo: bond,
unfreezeErrorKey: 'earn.fidelity_bond.error_unfreezing_utxos',
sendErrorKey: 'earn.fidelity_bond.renew.error_renewing_fidelity_bond',
}),
)
await result.current.sweep({ destination: 'bcrt1qdestination', tryFreezeAfterBroadcast: false })
expect(mocks.unfreezeMutateAsync).not.toHaveBeenCalled()
expect(mocks.freezeMutateAsync).not.toHaveBeenCalledWith(
expect.objectContaining({ body: { 'utxo-string': 'bond:0', freeze: true } }),
)
})
it('sweeps successfully: freezes other utxos, unfreezes the bond, then restores the others', async () => {
const bond = bondUtxo({ mixdepth: 0 })
const other = utxo({ utxo: 'other:0', mixdepth: 0, frozen: false })
mocks.walletInfo.jars = [jar(0, [bond, other])]
mocks.directSendMutateAsync.mockResolvedValue({ txinfo: { txid: 'renewed-tx' } })
const { result } = renderHook(() =>
useFidelityBondSweep({
walletFileName: 'wallet.jmdat',
utxo: bond,
unfreezeErrorKey: 'earn.fidelity_bond.error_unfreezing_utxos',
sendErrorKey: 'earn.fidelity_bond.renew.error_renewing_fidelity_bond',
}),
)
const txResult = await result.current.sweep({
destination: 'bcrt1qdestination',
tryFreezeAfterBroadcast: false,
})
expect(txResult).toEqual({ txinfo: { txid: 'renewed-tx' } })
expect(mocks.directSendMutateAsync).toHaveBeenCalledWith({
path: { walletname: 'wallet.jmdat' },
body: { mixdepth: 0, amount_sats: 0, destination: 'bcrt1qdestination' },
})
// the other utxo ends up unfrozen again post-broadcast
expect(mocks.unfreezeMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({ body: { 'utxo-string': 'other:0', freeze: false } }),
)
expect(mocks.walletInfoRefetch).toHaveBeenCalled()
})
it('does not re-freeze the bond if the sweep broadcast but the refetch after it fails', async () => {
const bond = bondUtxo({ mixdepth: 0 })
mocks.walletInfo.jars = [jar(0, [bond])]
mocks.directSendMutateAsync.mockResolvedValue({ txinfo: { txid: 'renewed-tx' } })
mocks.walletInfoRefetch.mockRejectedValue(new Error('refetch failed'))
const { result } = renderHook(() =>
useFidelityBondSweep({
walletFileName: 'wallet.jmdat',
utxo: bond,
unfreezeErrorKey: 'earn.fidelity_bond.error_unfreezing_utxos',
sendErrorKey: 'earn.fidelity_bond.renew.error_renewing_fidelity_bond',
}),
)
const txResult = await result.current.sweep({
destination: 'bcrt1qdestination',
tryFreezeAfterBroadcast: false,
})
expect(txResult).toEqual({ txinfo: { txid: 'renewed-tx' } })
expect(mocks.freezeMutateAsync).not.toHaveBeenCalledWith(
expect.objectContaining({ body: { 'utxo-string': 'bond:0', freeze: true } }),
)
})
})

View file

@ -56,6 +56,9 @@ export function useFidelityBondSweep({
setError(undefined)
const frozen: Utxo[] = []
let bondWasUnfrozen = false
let sweepBroadcasted = false
try {
// Freeze other UTXOs in the source jar so only the FB gets swept
for (const u of utxosToFreeze) {
@ -71,6 +74,7 @@ export function useFidelityBondSweep({
path: { walletname: walletFileName },
body: { 'utxo-string': utxo.utxo, freeze: false },
})
bondWasUnfrozen = true
}
const result = await directSend.mutateAsync({
@ -81,6 +85,7 @@ export function useFidelityBondSweep({
destination,
},
})
sweepBroadcasted = true
// Best-effort cleanup — tx already broadcast, don't throw on unfreeze failure
for (const u of frozen) {
@ -117,7 +122,6 @@ export function useFidelityBondSweep({
console.warn('onBroadcastSuccess failed', error)
}
await walletInfoRefetch()
return result
} catch (_ignoredOnPurpose: unknown) {
// Best-effort rollback — unfreeze UTXOs that were frozen before the error
@ -133,7 +137,32 @@ export function useFidelityBondSweep({
console.debug('Error while unfreezing previously frozen UTXO in error handling.')
}
}
// re-freeze the bond if it was unfrozen before the error. skip once the
// sweep already broadcast, the bond utxo is spent by then
if (bondWasUnfrozen && !sweepBroadcasted) {
try {
await freezeUtxo.mutateAsync({
path: { walletname: walletFileName },
body: { 'utxo-string': utxo.utxo, freeze: true },
throwOnError: true,
})
} catch (_ignoredOnPurpose: unknown) {
console.debug('Error while re-freezing fidelity bond UTXO in error handling.')
}
}
return undefined
} finally {
// refetch only if the sweep broadcast, and don't let a refetch error
// get treated as a sweep failure
if (sweepBroadcasted) {
try {
await walletInfoRefetch()
} catch (error: unknown) {
console.warn('Error while refetching wallet info after sweep.', error)
}
}
}
},
retry: false,

View file

@ -118,18 +118,42 @@ describe('EarnReportContent', () => {
vi.restoreAllMocks()
})
it('renders loading and empty report states', () => {
it('renders loading and report states', () => {
mocks.isLoading = true
mocks.isRefetching = false
mocks.entries = []
const { rerender } = render(<EarnReportContent enabled />)
expect(screen.getByText('spinner')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'global.refresh' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'global.download' })).not.toBeInTheDocument()
mocks.isLoading = false
mocks.isRefetching = true
mocks.entries = []
rerender(<EarnReportContent enabled />)
expect(screen.getByText('earn.alert_empty_report')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /earn\.report\.text_button_download_csv/u })).toBeDisabled()
expect(screen.getByRole('button', { name: 'global.refresh' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'global.download' })).toBeDisabled()
mocks.isLoading = false
mocks.isRefetching = false
mocks.entries = []
rerender(<EarnReportContent enabled />)
expect(screen.queryByText('earn.alert_empty_report')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'global.refresh' })).toBeEnabled()
expect(screen.getByRole('button', { name: 'global.download' })).toBeDisabled()
mocks.isLoading = false
mocks.isRefetching = false
mocks.entries = [entry({ earnedAmount: 100, notes: 'first maker note' })]
rerender(<EarnReportContent enabled />)
expect(screen.queryByText('earn.alert_empty_report')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'global.refresh' })).toBeEnabled()
expect(screen.getByRole('button', { name: 'global.download' })).toBeEnabled()
})
it('summarizes, filters, refreshes, paginates, and exports report rows', async () => {
@ -152,37 +176,78 @@ describe('EarnReportContent', () => {
expect(screen.getByText('chart:3')).toBeInTheDocument()
expect(screen.getByText('balance:600')).toBeInTheDocument()
expect(screen.getAllByText('balance:300').length).toBeGreaterThan(0)
expect(screen.getByText(/earn\.report\.text_report_summary:\{"count":3\}/u)).toBeInTheDocument()
expect(screen.getByText('earn.report.text_report_summary:{"count":3}')).toBeInTheDocument()
expect(screen.queryByText(/earn\.report\.text_report_summary_filtered:/u)).not.toBeInTheDocument()
expect(screen.getByText('first maker note')).toBeInTheDocument()
fireEvent.change(screen.getByPlaceholderText('earn.report.placeholder_search'), {
target: { value: 'second' },
})
expect(screen.getByText(/earn\.report\.text_report_summary_filtered/u)).toBeInTheDocument()
expect(screen.queryByText(/earn\.report\.text_report_summary:/u)).not.toBeInTheDocument()
expect(screen.getByText('earn.report.text_report_summary_filtered:{"count":1}')).toBeInTheDocument()
expect(screen.queryByText('first maker note')).not.toBeInTheDocument()
expect(screen.getByText('second maker note')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /earn\.report\.text_button_download_csv/u }))
const downloadButton = screen.getByRole('button', { name: 'global.download' })
expect(downloadButton).toBeEnabled()
fireEvent.click(downloadButton)
expect(mocks.createObjectURL).toHaveBeenCalled()
expect(anchorClick).toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'first-page' }))
fireEvent.click(screen.getByRole('button', { name: 'show-all' }))
fireEvent.click(screen.getByRole('button', { name: 'show-page-size' }))
fireEvent.click(screen.getByRole('button', { name: '' }))
const refreshButton = screen.getByRole('button', { name: 'global.refresh' })
expect(refreshButton).toBeEnabled()
fireEvent.click(refreshButton)
await waitFor(() => expect(mocks.refetch).toHaveBeenCalled())
createElement.mockRestore()
})
it('exports all filtered rows regardless of pagination', async () => {
mocks.entries = Array.from({ length: 30 }, (_, index) =>
entry({
notes: `maker note ${index + 1}`,
timestamp: new Date(Date.UTC(2026, 0, index + 1, 12)),
}),
)
render(<EarnReportContent enabled />)
expect(screen.getByText('pagination:30')).toBeInTheDocument()
expect(screen.getAllByText(/maker note/u)).toHaveLength(25)
fireEvent.click(screen.getByRole('button', { name: 'global.download' }))
const blob = mocks.createObjectURL.mock.calls[0]?.[0]
expect(blob).toBeInstanceOf(Blob)
if (!blob) {
throw new Error('CSV blob was not created')
}
const csv = await blob.text()
const lines = csv.trim().split('\n')
expect(lines).toHaveLength(31)
expect(lines[1]).toContain('2026-01-30T12:00:00.000Z')
expect(lines.at(-1)).toContain('2026-01-01T12:00:00.000Z')
})
it('adds demo rows in developer mode', () => {
mocks.developerMode = true
render(<EarnReportContent enabled />)
expect(screen.getByText('dev-badge')).toBeInTheDocument()
expect(screen.getByText('earn.report.text_report_summary:{"count":3}')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /earn\.report\.text_button_generate_demo_report/u }))
expect(screen.getByText('dev-badge')).toBeInTheDocument()
expect(screen.getByText(/earn\.report\.text_report_summary:\{"count":4\}/u)).toBeInTheDocument()
expect(screen.getByText('earn.report.text_report_summary:{"count":4}')).toBeInTheDocument()
})
})

View file

@ -11,14 +11,14 @@ import {
type SortingState,
useReactTable,
} from '@tanstack/react-table'
import { DownloadIcon, PlusIcon, RefreshCwIcon, SearchIcon } from 'lucide-react'
import { DownloadIcon, PlusIcon, RefreshCwIcon, SearchIcon, XIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { DevBadge } from '@/components/dev/DevBadge'
import { useQueryYieldgenReport, type EarnReportEntry } from '@/components/earn/report/hooks/useQueryYieldgenReport'
import { Alert, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { InputGroup, InputGroupAddon, InputGroupInput } from '@/components/ui/input-group'
import { Balance } from '@/components/ui/jam/Balance'
import { SortIcon } from '@/components/ui/jam/SortIcon'
import { TablePagination } from '@/components/ui/jam/TablePagination'
@ -144,11 +144,11 @@ export const EarnReportContent = ({ className, enabled }: EarnReportContentProps
}, [isShowAll, allEntries.length, table])
const visibleRows = table.getRowModel().rows
const exportRows = table.getSortedRowModel().rows
// Export visible entries as CSV
const downloadCsv = useCallback(() => {
const header = 'timestamp,cj_amount,input_count,input_amount,fee,earned,confirm_minutes,notes'
const rows = visibleRows.map((row) =>
const rows = exportRows.map((row) =>
[
row.original.timestamp.toISOString(),
row.original.cjTotalAmount ?? '',
@ -170,7 +170,7 @@ export const EarnReportContent = ({ className, enabled }: EarnReportContentProps
setTimeout(() => {
URL.revokeObjectURL(url)
}, 0)
}, [visibleRows])
}, [exportRows])
const earnedTotal = useMemo(() => sumEarned(allEntries, BITCOIN_GENESIS_DATE), [allEntries])
@ -212,23 +212,46 @@ export const EarnReportContent = ({ className, enabled }: EarnReportContentProps
<EarnReportChart entries={allEntries} />
{/* Toolbar: search + refresh */}
<div className="flex flex-wrap items-center gap-2">
<div className="relative min-w-[12rem] flex-1">
<SearchIcon className="text-muted-foreground absolute top-1/2 left-2.5 size-4 -translate-y-1/2" />
<Input
placeholder={t('earn.report.placeholder_search')}
value={globalFilter}
onChange={(event) => table.setGlobalFilter(event.target.value)}
className="pl-8"
/>
<div className="flex w-full flex-wrap items-center justify-end gap-2">
<div className="flex w-full flex-wrap items-center gap-2 sm:w-auto">
<InputGroup className="min-w-[120px] flex-1 sm:max-w-[360px] sm:min-w-[220px]">
<InputGroupInput
className="text-xs sm:text-sm"
value={globalFilter}
onChange={(event) => table.setGlobalFilter(event.target.value)}
placeholder={t('earn.report.placeholder_search')}
/>
<InputGroupAddon align="inline-start">
<SearchIcon />
</InputGroupAddon>
<InputGroupAddon align="inline-end">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => table.setGlobalFilter('')}
title={t('global.clear')}
className={globalFilter.length === 0 ? 'invisible' : undefined}
>
<XIcon />
</Button>
</InputGroupAddon>
</InputGroup>
<Button variant="outline" onClick={() => void refetch()} disabled={!enabled || isRefetching}>
<RefreshCwIcon className={isRefetching ? 'animate-spin' : undefined} />
<span className="hidden sm:inline">{t('global.refresh')}</span>
</Button>
<Button
className="hover:[&>svg]:motion-safe:animate-bounce"
variant="outline"
onClick={downloadCsv}
disabled={!enabled || allEntries.length === 0}
>
<DownloadIcon className="group/download" />
<span className="hidden sm:inline">{t('global.download')}</span>
</Button>
</div>
<Button variant="outline" size="icon" onClick={() => void refetch()} disabled={isRefetching}>
<RefreshCwIcon className={isRefetching ? 'animate-spin' : ''} />
</Button>
<Button variant="outline" size="sm" onClick={downloadCsv} disabled={allEntries.length === 0}>
<DownloadIcon />
{t('earn.report.text_button_download_csv')}
</Button>
<span className="text-muted-foreground ml-2 text-sm font-normal">
{globalFilter === ''
@ -236,7 +259,7 @@ export const EarnReportContent = ({ className, enabled }: EarnReportContentProps
: t('earn.report.text_report_summary_filtered', { count: table.getFilteredRowModel().rows.length })}
</span>
{isDeveloperMode ? (
<Button variant="outline" size="sm" onClick={addDemoEntry}>
<Button className="min-w-0 shrink overflow-hidden" variant="outline" onClick={addDemoEntry}>
<PlusIcon />
{t('earn.report.text_button_generate_demo_report')}
<DevBadge />

View file

@ -4,6 +4,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { DUMMY_SEED_PHRASE, pseudoRandomInteger } from '@/lib/utils'
import { flushActUpdates } from '@/test/flushActUpdates'
import { withRuntimeLocale } from '@/test/withRuntimeLocale'
import type { BlockHeight } from '@/types/global'
import { ImportDetailsForm } from './ImportDetailsForm'
@ -64,24 +65,26 @@ describe('ImportDetailsForm', () => {
})
it('warns and does not submit when the blockheight is larger than current blockheight', async () => {
const onSubmit = vi.fn()
const currentBlockHeight = pseudoRandomInteger(1, Number.MAX_SAFE_INTEGER - 1)
render(
<ImportDetailsForm
onSubmit={onSubmit}
sessionInfo={{ ...mockedSessionInfo, block_height: currentBlockHeight }}
/>,
)
typeBlockheight(currentBlockHeight + 1)
fireEvent.submit(document.querySelector('form')!)
await waitFor(() =>
expect(
screen.getByText(
`import_wallet.import_details.feedback_invalid_blockheight {"min":"0","max":"${currentBlockHeight.toLocaleString()}"}`,
),
).toBeInTheDocument(),
)
expect(onSubmit).not.toHaveBeenCalled()
await withRuntimeLocale('en-US', async () => {
const onSubmit = vi.fn()
const currentBlockHeight = pseudoRandomInteger(1, Number.MAX_SAFE_INTEGER - 1)
render(
<ImportDetailsForm
onSubmit={onSubmit}
sessionInfo={{ ...mockedSessionInfo, block_height: currentBlockHeight }}
/>,
)
typeBlockheight(currentBlockHeight + 1)
fireEvent.submit(document.querySelector('form')!)
await waitFor(() =>
expect(
screen.getByText(
`import_wallet.import_details.feedback_invalid_blockheight {"min":"0","max":"${currentBlockHeight.toLocaleString()}"}`,
),
).toBeInTheDocument(),
)
expect(onSubmit).not.toHaveBeenCalled()
})
})
it('submits when all values are valid', async () => {

View file

@ -77,6 +77,9 @@ vi.mock('@/hooks/useFeatures', () => ({
vi.mock('@/store/jamSettingsStore', () => ({
useDeveloperMode: () => ({ enabled: mocks.developerMode }),
usePreviewFeatures: () => ({
'tx-history': true,
}),
}))
vi.mock('../dev/DevBadge', () => ({

View file

@ -6,6 +6,7 @@ import {
CurlyBracesIcon,
DownloadIcon,
HandCoinsIcon,
HistoryIcon,
LogsIcon,
MilkIcon,
NotebookTabsIcon,
@ -40,14 +41,16 @@ import { isDebugFeatureEnabled, isDevMode } from '@/constants/debugFeatures'
import { POST_LOGIN_TOUR_EVENT } from '@/constants/onboarding'
import { routes } from '@/constants/routes'
import { useFeatures } from '@/hooks/useFeatures'
import { useDeveloperMode } from '@/store/jamSettingsStore'
import { useDeveloperMode, usePreviewFeatures } from '@/store/jamSettingsStore'
import { DevBadge } from '../dev/DevBadge'
import { Badge } from '../ui/badge'
export function AppSidebar({ side }: Pick<React.ComponentProps<typeof Sidebar>, 'side'>) {
const { t } = useTranslation()
const { toggleSidebar } = useSidebar()
const { enabled: isDeveloperMode } = useDeveloperMode()
const previewFeatures = usePreviewFeatures()
const { isFeatureEnabled } = useFeatures()
const mainItems = useMemo(
@ -94,8 +97,18 @@ export function AppSidebar({ side }: Pick<React.ComponentProps<typeof Sidebar>,
url: routes.walletJarsDetails,
icon: MilkIcon,
},
...(previewFeatures?.['tx-history'] !== true
? []
: [
{
title: t('sidebar.item_history.label'),
url: routes.txHistory,
icon: HistoryIcon,
preview: true,
},
]),
],
[t],
[t, previewFeatures],
)
const settingsItems = useMemo(
@ -180,6 +193,7 @@ export function AppSidebar({ side }: Pick<React.ComponentProps<typeof Sidebar>,
<Link to={item.url}>
<item.icon />
<span>{item.title}</span>
{item.preview ? <Badge variant="muted">{/* TODO: i18n */ 'Preview'}</Badge> : null}
</Link>
</SidebarMenuButton>
{item.subitems?.length && (

View file

@ -3,8 +3,8 @@ import { RefreshCwIcon, DownloadIcon, ArrowDownIcon, SearchIcon, XIcon } from 'l
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { cn, delayedPromise } from '@/lib/utils'
import { InputGroup, InputGroupAddon, InputGroupInput } from '../ui/input-group'
interface LogViewerProps {
fileName: string
@ -143,32 +143,43 @@ export function LogViewer({ fileName, value, refresh }: LogViewerProps) {
return (
<Card className="flex flex-1 flex-col overflow-hidden pb-0">
<CardHeader className="flex flex-col justify-center gap-2 sm:flex-row sm:items-center sm:justify-between">
<CardTitle className="font-mono break-all select-all">{fileName}</CardTitle>
<CardHeader className="flex flex-col justify-center gap-4 md:flex-row md:items-center md:justify-between">
<CardTitle className="font-mono break-all break-keep select-all">{fileName}</CardTitle>
{/* Search + action buttons */}
<div className="flex w-full flex-wrap items-center justify-end gap-2 sm:w-auto sm:flex-nowrap">
<div className="relative min-w-0 flex-1 sm:max-w-[360px] sm:min-w-[220px]">
<SearchIcon className="text-muted-foreground absolute top-1/2 left-2 h-4 w-4 -translate-y-1/2" />
<Input
<div className="flex w-full flex-wrap items-center justify-end gap-2 sm:w-auto">
<InputGroup className="min-w-[120px] flex-1 sm:max-w-[360px] sm:min-w-[220px]">
<InputGroupInput
className="text-xs sm:text-sm"
value={searchValue}
onChange={(event) => setSearchValue(event.target.value)}
className="h-9 pr-8 pl-8 text-xs"
placeholder={t('logs.placeholder_search')}
aria-label={t('logs.label_search')}
/>
{searchValue.length > 0 && (
<InputGroupAddon align="inline-start">
<SearchIcon />
</InputGroupAddon>
<InputGroupAddon align="inline-end">
<Button
type="button"
variant="ghost"
size="icon"
className="absolute top-1/2 right-1 h-7 w-7 -translate-y-1/2"
onClick={() => setSearchValue('')}
title={t('global.clear')}
className={searchValue.length === 0 ? 'invisible' : undefined}
>
<XIcon className="h-4 w-4" />
<XIcon />
</Button>
)}
</div>
</InputGroupAddon>
</InputGroup>
<Button
variant="outline"
onClick={() => void handleRefresh()}
disabled={isLoadingRefresh}
title={t('global.refresh')}
>
<RefreshCwIcon className={isLoadingRefresh ? 'animate-spin' : undefined} />
<span className="hidden sm:inline">{t('global.refresh')}</span>
</Button>
<Button
className="hover:[&>svg]:motion-safe:animate-bounce"
variant="outline"
@ -179,15 +190,6 @@ export function LogViewer({ fileName, value, refresh }: LogViewerProps) {
<DownloadIcon className="group/download" />
<span className="hidden sm:inline">{t('global.download')}</span>
</Button>
<Button
variant="outline"
onClick={() => void handleRefresh()}
disabled={isLoadingRefresh}
title={t('global.refresh')}
>
<RefreshCwIcon className={cn({ 'animate-spin': isLoadingRefresh })} />
<span className="hidden sm:inline">{t('global.refresh')}</span>
</Button>
</div>
</CardHeader>
{/* Search match count */}

View file

@ -4,6 +4,7 @@ import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { useStore } from 'zustand'
import { Alert } from '@/components/ui/alert'
import * as JAM from '@/constants/jam'
import { fetchLog } from '@/lib/api/jam'
import { getErrorReason } from '@/lib/errorReason'
import { authStore } from '@/store/authStore'
@ -38,7 +39,7 @@ export function useJmwalletdStdoutLog({ enabled = true }: UseJmwalletdStdoutLogP
refetchInterval: (query) => {
if (!enabled || token === undefined) return false
if (query.state.error) return false
return 2_500
return JAM.JMWALLETD_LOGS_POLLING_INTERVAL
},
// Keep previous data during refetch to prevent content flicker on slower networks.
placeholderData: (previousData) => previousData,

View file

@ -1,9 +1,10 @@
import { useState, type ComponentProps } from 'react'
import { RefreshCwIcon, WalletIcon } from 'lucide-react'
import { LanguagesIcon, RefreshCwIcon, WalletIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { LanguageSelectorDropdownMenu } from '@/components/ui/jam/LanguageSelector'
import { WalletLoadErrorAlert } from '@/components/ui/jam/WalletLoadErrorAlert'
import { Skeleton } from '@/components/ui/skeleton'
import { Spinner } from '@/components/ui/spinner'
@ -71,22 +72,36 @@ export const LoginCard = ({
<>
<Card className="w-full max-w-md">
<CardHeader className="flex flex-col items-center space-y-2">
<div className="bg-primary/10 mb-4 flex h-12 w-12 items-center justify-center rounded-full">
{listWalletsFetching ? (
<Spinner className="size-6" />
) : (
<div className="mb-4 grid w-full grid-cols-3">
<div></div>
<Button
type="button"
variant="default"
size="icon-xxl"
className="bg-primary/10 text-primary/80 hover:text-primary self-center justify-self-center rounded-full"
title={t('global.refresh')}
aria-label={t('global.refresh')}
onClick={() => void onReloadClick()}
disabled={listWalletsFetching}
>
{listWalletsFetching ? <Spinner className="size-6" /> : <WalletIcon className="size-5" />}
<span className="sr-only">{t('global.refresh')}</span>
</Button>
<LanguageSelectorDropdownMenu align="end">
<Button
type="button"
variant="ghost"
size="icon"
className="text-primary h-10 w-10 rounded-full"
title={t('global.retry')}
aria-label={t('global.retry')}
onClick={() => void onReloadClick()}
aria-label={t('settings.label_select_language_aria_label')}
title={t('settings.label_select_language_title')}
className="self-start justify-self-end"
>
<WalletIcon />
<LanguagesIcon className="size-5" />
<span className="sr-only">{t('settings.label_select_language')}</span>
</Button>
)}
</LanguageSelectorDropdownMenu>
</div>
<CardTitle className="text-center text-2xl font-bold break-words">{t('login.title')}</CardTitle>
{listWalletsLoading ? (
@ -103,7 +118,7 @@ export const LoginCard = ({
) : null}
</CardHeader>
<CardContent className="space-y-6">
<CardContent className="flex flex-col gap-6">
{listWalletsError ? (
<>
<WalletLoadErrorAlert reason={getErrorReason(listWalletsError, t('global.errors.reason_unknown'))} />

View file

@ -0,0 +1,135 @@
import { sha256 } from '@noble/hashes/sha2.js'
import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import type { PaymentAttempt } from '@/context/JamSessionInfoContext'
import type { Jar } from '@/context/JamWalletInfoContext'
import { TX_FEE_UNITS } from '@/lib/feeConfig'
import { ActiveCollaborativeSendAlert } from './ActiveCollaborativeSendAlert'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => (options ? `${key}:${JSON.stringify(options)}` : key),
}),
Trans: ({ i18nKey, children }: { i18nKey: string; children?: React.ReactNode }) => <span>{i18nKey || children}</span>,
}))
vi.mock('@/context/JamDisplayContext', () => ({
useJamDisplayContext: () => ({ addressChunkingEnabled: false }),
}))
const sampleJars: Jar[] = [
{
balanceSummary: {
calculatedAvailableBalanceInSats: 100_000,
calculatedConfirmedAvailableBalanceInSats: 100_000,
calculatedAvailableFrozenBalanceInSats: 0,
calculatedFrozenOrLockedBalanceInSats: 0,
calculatedTotalBalanceInSats: 100_000,
},
color: '#e2b86a',
jarIndex: 0,
name: 'Jar Zero',
utxos: [],
},
{
balanceSummary: {
calculatedAvailableBalanceInSats: 50_000,
calculatedConfirmedAvailableBalanceInSats: 50_000,
calculatedAvailableFrozenBalanceInSats: 0,
calculatedFrozenOrLockedBalanceInSats: 0,
calculatedTotalBalanceInSats: 50_000,
},
color: '#3b5ba9',
jarIndex: 1,
name: 'Jar One',
utxos: [],
},
]
const sampleAttempt: PaymentAttempt = {
createdAt: 1_000,
utxosHashHex: bytesToHex(sha256(hexToBytes('00'))),
walletFileName: 'test.jmdat',
data: {
amount: { amount: 21_000, isSweep: false as const, sweepAmount: undefined },
destination: { address: 'bcrt1qdestinationaddress123456789', fromJar: undefined },
isCoinJoin: true,
numCollaborators: 5,
source: { fromJar: 0 as const },
txFee: { txFeeInBlocks: 3, txFeeInSatsPerVbyte: undefined, txFeeUnit: TX_FEE_UNITS.BLOCKS },
},
}
describe('ActiveCollaborativeSendAlert', () => {
it('renders title and details for active collaborative send to external address', () => {
render(
<ActiveCollaborativeSendAlert
paymentAttempt={sampleAttempt}
jars={sampleJars}
isAborting={false}
onAbort={vi.fn()}
/>,
)
expect(screen.getByText('send.text_coinjoin_already_running')).toBeInTheDocument()
expect(screen.getByText('Jar Zero')).toBeInTheDocument()
expect(screen.getByText('#0')).toBeInTheDocument()
expect(screen.getByText(/bcrt1qdestinationaddress123456789/u)).toBeInTheDocument()
expect(screen.getAllByText('5').length).toBeGreaterThan(0)
expect(screen.getByRole('button', { name: 'global.abort' })).toBeInTheDocument()
})
it('renders destination jar badge when destination jar is present', () => {
const internalAttempt = {
...sampleAttempt,
data: {
...sampleAttempt.data,
destination: { address: 'bc1qinternal', fromJar: 1 as const },
},
}
render(
<ActiveCollaborativeSendAlert
paymentAttempt={internalAttempt}
jars={sampleJars}
isAborting={false}
onAbort={vi.fn()}
/>,
)
expect(screen.getByText('Jar One')).toBeInTheDocument()
expect(screen.getByText('#1')).toBeInTheDocument()
})
it('calls onAbort when abort button is clicked', async () => {
const user = userEvent.setup()
const onAbort = vi.fn()
render(
<ActiveCollaborativeSendAlert
paymentAttempt={sampleAttempt}
jars={sampleJars}
isAborting={false}
onAbort={onAbort}
/>,
)
await user.click(screen.getByRole('button', { name: 'global.abort' }))
expect(onAbort).toHaveBeenCalledTimes(1)
})
it('disables abort button when stopping coinjoin', () => {
render(
<ActiveCollaborativeSendAlert
paymentAttempt={sampleAttempt}
jars={sampleJars}
isAborting={true}
onAbort={vi.fn()}
/>,
)
expect(screen.getByRole('button', { name: 'global.abort' })).toBeDisabled()
})
})

View file

@ -0,0 +1,144 @@
import { useMemo } from 'react'
import { MilkIcon, UsersIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Badge } from '@/components/ui/badge'
import { jarBadgeVariant } from '@/components/ui/badge-variants'
import { Button } from '@/components/ui/button'
import { Address } from '@/components/ui/jam/Address'
import { Balance } from '@/components/ui/jam/Balance'
import { CookingPotIcon } from '@/components/ui/jam/CookingPotIcon'
import type { PaymentAttempt } from '@/context/JamSessionInfoContext'
import type { Jar } from '@/context/JamWalletInfoContext'
import { cn } from '@/lib/utils'
import { Item, ItemActions, ItemContent, ItemDescription, ItemFooter, ItemHeader, ItemTitle } from '../ui/item'
import { Label } from '../ui/label'
export interface ActiveCollaborativeSendAlertProps {
paymentAttempt?: PaymentAttempt
jars: Jar[]
isAborting: boolean
onAbort: () => void
}
export const ActiveCollaborativeSendAlert = ({
paymentAttempt,
jars,
isAborting,
onAbort,
}: ActiveCollaborativeSendAlertProps) => {
const { t } = useTranslation()
const data = paymentAttempt?.data
const sourceJar = useMemo(() => {
if (data?.source?.fromJar === undefined) return undefined
return jars.find((index) => index.jarIndex === data.source.fromJar)
}, [jars, data])
const destinationJar = useMemo(() => {
if (data?.destination?.fromJar === undefined) return undefined
return jars.find((index) => index.jarIndex === data.destination.fromJar)
}, [jars, data])
return (
<>
<Item
variant="outline"
className={cn({
//'ring-brand-success ring-1': value.status.completed,
//'ring-destructive ring-1': value.status.failed,
'ring-ring/50 ring-2': true,
})}
>
<ItemHeader className="mb-4">
<div>
<ItemTitle className="text-base">{t('send.text_coinjoin_already_running')}</ItemTitle>
<ItemDescription></ItemDescription>
</div>
</ItemHeader>
<ItemContent className="flex flex-col gap-2 md:flex-row md:gap-8">
<div className="col-span-full flex justify-center md:col-span-1">
<CookingPotIcon
className="animate-pulse"
sourceJarIndex={data?.source?.fromJar}
destinationJarIndex={data?.destination?.fromJar}
/>
</div>
{data && (
<>
<div className="grid grid-cols-1 gap-4">
{sourceJar ? (
<div className="flex min-w-0 items-start gap-4">
<MilkIcon className="mt-0.5 shrink-0" />
<div className="min-w-0 flex-1 space-y-1">
<Label className="font-semibold">{t('send.confirm_send_modal.label_source_jar')}</Label>
<Badge variant={jarBadgeVariant(sourceJar.jarIndex)}>
{sourceJar.name} <span className="text-xs">#{sourceJar.jarIndex.toLocaleString()}</span>
</Badge>
</div>
</div>
) : null}
<div className="flex min-w-0 items-start gap-4">
<UsersIcon className="mt-0.5 shrink-0" />
<div className="min-w-0 flex-1 space-y-1">
<Label className="font-semibold">
{t('send.confirm_send_modal.label_amount')}
{data.amount.isSweep ? (
<Badge variant="outline" className="text-xs">
Sweep
</Badge>
) : null}
</Label>
{data.amount.isSweep ? (
<Balance valueString={String(data.amount.sweepAmount)} />
) : (
<Balance valueString={String(data.amount.amount)} />
)}
</div>
</div>
{data.numCollaborators !== undefined ? (
<div className="flex min-w-0 items-start gap-4">
<UsersIcon className="mt-0.5 shrink-0" />
<div className="min-w-0 flex-1 space-y-1">
<Label className="font-semibold">{t('send.confirm_send_modal.label_num_collaborators')}</Label>
{data.numCollaborators.toLocaleString()}
</div>
</div>
) : null}
<div className="col-span-full flex min-w-0 items-start gap-4">
<UsersIcon className="mt-0.5 shrink-0" />
<div className="min-w-0 flex-1 space-y-1">
<Label className="font-semibold">{t('send.confirm_send_modal.label_recipient')}</Label>
{destinationJar && (
<Badge variant={jarBadgeVariant(destinationJar.jarIndex)}>
{destinationJar.name}{' '}
<span className="text-xs">#{destinationJar.jarIndex.toLocaleString()}</span>
</Badge>
)}
<Address
className="text-muted-foreground font-mono text-xs"
value={data.destination.address}
copyable={true}
/>
</div>
</div>
</div>
</>
)}
</ItemContent>
<ItemFooter>
<ItemActions>
<Button type="button" variant="destructive" onClick={onAbort} disabled={isAborting}>
{t('global.abort')}
</Button>
</ItemActions>
</ItemFooter>
</Item>
</>
)
}

View file

@ -1,97 +1,10 @@
import { AlertTriangleIcon } from 'lucide-react'
import { Trans, useTranslation } from 'react-i18next'
import { Link } from 'react-router-dom'
import type { SweepPreconditionSummary } from '@/components/sweep/preconditions'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Balance } from '@/components/ui/jam/Balance'
import { JAM_JM_RETRIES_DOCS_URL } from '@/constants/jam'
import { routes } from '@/constants/routes'
import { shortenStringMiddle } from '@/lib/utils'
import { JarBadge } from '../earn/fidelity-bond/FidelityBondDialogParts'
import { PreconditionAlert } from '@/components/ui/jam/PreconditionAlert'
interface SendCoinjoinPreconditionAlertProps {
summary: SweepPreconditionSummary
}
const RetryLockedUtxoList = ({ summary }: SendCoinjoinPreconditionAlertProps) => {
const { t } = useTranslation()
return (
<>
<Trans
i18nKey="send.coinjoin_precondition.hint_missing_retries"
components={{
'1': (
<a
key="retries-doc-link"
className="font-semibold"
href={JAM_JM_RETRIES_DOCS_URL}
target="_blank"
rel="noopener noreferrer"
/>
),
}}
/>
<div className="mt-3 p-2">
<div className="mb-2 text-xs font-semibold">{t('global.utxos')}</div>
<ul className="list-inside list-[circle] space-y-2">
{summary.retryLockedUtxos.map((utxo) => (
<li key={utxo.utxo} className="space-x-2">
<span className="slashed-zero tabular-nums">
<JarBadge jarIndex={utxo.mixdepth} />
</span>
<span className="font-mono select-all">{shortenStringMiddle(utxo.utxo, 26)}</span>
<Balance valueString={String(utxo.value)} />
</li>
))}
</ul>
</div>
</>
)
}
export const SendCoinjoinPreconditionAlert = ({ summary }: SendCoinjoinPreconditionAlertProps) => {
const { t } = useTranslation()
if (summary.isFulfilled) {
return null
}
return (
<Alert variant="warning">
<AlertTriangleIcon />
<AlertTitle>{t('send.coinjoin_precondition.title')}</AlertTitle>
<AlertDescription>
<ul className="list-outside list-disc space-y-2">
{summary.numberOfNonFrozenFidelityBondOutputs === 0 ? null : (
<li>
<Trans
i18nKey="send.coinjoin_precondition.hint_non_frozen_fidelity_bond"
values={{ count: summary.numberOfNonFrozenFidelityBondOutputs }}
components={{
'1': <Link to={routes.walletJarsDetails} className="font-semibold" />,
}}
/>
</li>
)}
{summary.numberOfMissingUtxos === 0 ? null : (
<li>
{t('send.coinjoin_precondition.hint_missing_utxos', {
minConfirmations: summary.options.minConfirmations,
})}
</li>
)}
{summary.numberOfMissingConfirmations === 0 ? null : (
<li>
{t('send.coinjoin_precondition.hint_missing_confirmations', {
minConfirmations: summary.options.minConfirmations,
amountOfMissingConfirmations: summary.numberOfMissingConfirmations,
})}
</li>
)}
{summary.retryLockedUtxos.length === 0 ? null : <li>{<RetryLockedUtxoList summary={summary} />}</li>}
</ul>
</AlertDescription>
</Alert>
)
return <PreconditionAlert summary={summary} i18nPrefix="send.coinjoin_precondition" />
}

View file

@ -18,9 +18,22 @@ const h = vi.hoisted(() => {
},
toastSuccess: vi.fn<(message: string) => void>(),
toastError: vi.fn<(message: string) => void>(),
hasOrders: true,
orderbookIsLoading: false,
orderbookError: false,
}
})
vi.mock('@/hooks/useQueryOrderbook', () => ({
useQueryOrderbook: () => ({
hasOrders: h.hasOrders,
queryResult: {
isLoading: h.orderbookIsLoading,
isError: h.orderbookError,
},
}),
}))
vi.mock('sonner', () => ({
toast: {
success: (message: string) => {
@ -33,11 +46,21 @@ vi.mock('sonner', () => ({
}))
vi.mock('react-i18next', () => ({
Trans: ({ i18nKey }: { i18nKey: string }) => <span>{i18nKey}</span>,
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => key + (options ? ' ' + JSON.stringify(options) : ''),
}),
}))
vi.stubGlobal(
'ResizeObserver',
class ResizeObserver {
observe = vi.fn()
unobserve = vi.fn()
disconnect = vi.fn()
},
)
vi.mock('@/context/JamWalletInfoContext', () => ({
useDetectNetwork: () => ({ network: 'mainnet' }),
}))
@ -156,6 +179,9 @@ describe('SendForm', () => {
h.getaddressResult = { data: { address: h.DEFAULT_NEW_DUMMY_ADDRESS_0 }, error: undefined }
h.toastSuccess = vi.fn<(message: string) => void>()
h.toastError = vi.fn<(message: string) => void>()
h.hasOrders = true
h.orderbookIsLoading = false
h.orderbookError = false
})
it('renders the core fields', () => {
@ -368,4 +394,49 @@ describe('SendForm', () => {
await flushActUpdates()
})
it('shows empty orderbook warning when collaborative send is selected and orderbook is empty', () => {
h.hasOrders = false
renderForm()
expect(screen.getByText('orderbook.alert_precheck_empty_title')).toBeInTheDocument()
})
it('does not show empty orderbook warning when orderbook is loading or has error', () => {
h.hasOrders = false
h.orderbookIsLoading = true
const { rerender } = renderForm()
expect(screen.queryByText('orderbook.alert_precheck_empty_title')).not.toBeInTheDocument()
h.orderbookIsLoading = false
h.orderbookError = true
rerender(
<SendForm
onSubmit={vi.fn()}
walletFileName="test.jmdat"
jars={mockJars}
walletBalanceSummary={mockBalanceSummary}
addressSummary={mockAddressSummary}
feeConfigValues={mockFeeConfigValues}
/>,
)
expect(screen.queryByText('orderbook.alert_precheck_empty_title')).not.toBeInTheDocument()
})
it('does not show empty orderbook warning when non-collaborative send is selected', () => {
h.hasOrders = false
renderForm()
expect(screen.getByText('orderbook.alert_precheck_empty_title')).toBeInTheDocument()
const optionsAccordion = screen.getByRole('button', { name: /send.sending_options/i })
fireEvent.click(optionsAccordion)
const collaborativeSwitch = document.querySelector('#switch-is-collaborative-transaction')
expect(collaborativeSwitch).toBeInTheDocument()
fireEvent.click(collaborativeSwitch!)
expect(screen.queryByText('orderbook.alert_precheck_empty_title')).not.toBeInTheDocument()
})
})

View file

@ -14,6 +14,7 @@ import { MAX_NUM_COLLABORATORS } from '@/constants/jam'
import { JM_MINIMUM_MAKERS_DEFAULT, JM_TAKER_UTXO_AGE } from '@/constants/jm'
import { useDetectNetwork, type AddressSummary, type Jar } from '@/context/JamWalletInfoContext'
import { useApiClient } from '@/hooks/useApiClient'
import { useQueryOrderbook } from '@/hooks/useQueryOrderbook'
import type { BalanceSummary } from '@/lib/balanceSummary'
import { parseBip21Uri, type Bip21ParseResult } from '@/lib/bip21'
import type { JamFeeConfigValues } from '@/lib/feeConfig'
@ -32,6 +33,7 @@ import { Input } from '../ui/input'
import { inputVariants } from '../ui/input-variants'
import { Address } from '../ui/jam/Address'
import { SatSymbol } from '../ui/jam/CurrencySymbol'
import { OrderbookEmptyAlert } from '../ui/jam/OrderbookEmptyAlert'
import { SelectableJar } from '../ui/jam/SelectableJar'
import { Slider } from '../ui/slider'
import { Spinner } from '../ui/spinner'
@ -182,6 +184,16 @@ export function SendForm({
const isCoinJoin = useWatch({ control, name: 'isCoinJoin' })
const collaboratorCount = useWatch({ control, name: 'numCollaborators' })
const {
hasOrders,
queryResult: {
isLoading: orderbookCheckIsLoading,
isFetching: orderbookCheckIsFetching,
isError: orderbookCheckIsError,
refetch: orderbookRefetch,
},
} = useQueryOrderbook()
const destinationAddressInfo = useMemo(() => {
try {
return destinationAddress !== undefined ? getAddressInfo(destinationAddress) : undefined
@ -362,6 +374,9 @@ export function SendForm({
{hasCoinjoinPreconditionWarning && coinjoinPreconditionSummary && (
<SendCoinjoinPreconditionAlert summary={coinjoinPreconditionSummary} />
)}
{isCoinJoin && !orderbookCheckIsLoading && !orderbookCheckIsError && !hasOrders && (
<OrderbookEmptyAlert isChecking={orderbookCheckIsFetching} onCheckClick={orderbookRefetch} />
)}
</div>
<div className="space-y-2">

View file

@ -26,6 +26,7 @@ const mocks = vi.hoisted(() => ({
stopCoinjoinRefetch: vi.fn(),
takerRunning: false,
schedulerRunning: false,
makerRunning: false,
toastError: vi.fn(),
toastInfo: vi.fn(),
toastSuccess: vi.fn(),
@ -33,6 +34,9 @@ const mocks = vi.hoisted(() => ({
walletInfoIsFetching: false,
walletInfoIsLoading: false,
waitForUtxosToBeSpent: [] as string[],
hasOrders: true,
orderbookIsLoading: false,
orderbookError: null as Error | null,
}))
vi.mock('@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query', () => ({
@ -72,11 +76,23 @@ vi.mock('@tanstack/react-query', () => ({
}))
vi.mock('react-i18next', () => ({
Trans: ({ i18nKey, values }: { i18nKey: string; values?: unknown }) =>
values ? `${i18nKey}:${JSON.stringify(values)}` : i18nKey,
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => (options ? `${key}:${JSON.stringify(options)}` : key),
}),
}))
vi.mock('@/hooks/useQueryOrderbook', () => ({
useQueryOrderbook: () => ({
hasOrders: mocks.hasOrders,
queryResult: {
isLoading: mocks.orderbookIsLoading,
error: mocks.orderbookError,
},
}),
}))
vi.mock('sonner', () => ({
toast: {
error: mocks.toastError,
@ -145,6 +161,10 @@ vi.mock('@/store/jamSettingsStore', () => ({
useDeveloperMode: () => ({ enabled: true }),
}))
vi.mock('@/context/JamDisplayContext', () => ({
useJamDisplayContext: () => ({ addressChunkingEnabled: false }),
}))
vi.mock('@/context/JamSessionInfoContext', () => ({
useJamSessionInfoContext: () => ({
clearCurrentPaymentAttempt: mocks.clearCurrentPaymentAttempt,
@ -157,6 +177,9 @@ vi.mock('@/context/JamSessionInfoContext', () => ({
running: mocks.schedulerRunning,
},
},
makerInfo: {
running: mocks.makerRunning,
},
}),
}))
@ -323,6 +346,9 @@ describe('SendPage', () => {
mocks.walletInfoIsFetching = false
mocks.walletInfoIsLoading = false
mocks.waitForUtxosToBeSpent = []
mocks.hasOrders = true
mocks.orderbookIsLoading = false
mocks.orderbookError = null
jmSessionStore.setState({
state: {
coinjoin_in_process: false,
@ -510,15 +536,7 @@ describe('SendPage', () => {
})
it('shows the maker running warning', async () => {
jmSessionStore.setState({
state: {
coinjoin_in_process: false,
maker_running: true,
session: true,
wallet_name: 'wallet.jmdat',
rescanning: false,
},
})
mocks.makerRunning = true
render(<SendPage walletFileName="wallet.jmdat" />)

View file

@ -20,6 +20,7 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { FeeConfigErrorAlert } from '@/components/ui/jam/FeeConfigErrorAlert'
import { PageLoading } from '@/components/ui/jam/PageLoading'
import PageTitle from '@/components/ui/jam/PageTitle'
import * as JAM from '@/constants/jam'
import { useJamSessionInfoContext } from '@/context/JamSessionInfoContext'
import {
useAddressSummary,
@ -36,7 +37,7 @@ import { useUtxoSelectionDialog } from '@/hooks/useUtxoSelectionDialog'
import { getErrorReason } from '@/lib/errorReason'
import * as fb from '@/lib/fidelityBondUtils'
import { withMutationDelay } from '@/lib/queryClient'
import { scrollToTop, type WalletFileName } from '@/lib/utils'
import { cn, scrollToTop, type WalletFileName } from '@/lib/utils'
import { useDeveloperMode } from '@/store/jamSettingsStore'
import { jmSessionStore } from '@/store/jmSessionStore'
import { jmTxStore, type JmTxInfo } from '@/store/jmTxStore'
@ -44,6 +45,7 @@ import type { JarIndex } from '@/types/global'
import { Button } from '../ui/button'
import { Card, CardContent } from '../ui/card'
import { Spinner } from '../ui/spinner'
import { ActiveCollaborativeSendAlert } from './ActiveCollaborativeSendAlert'
import { PaymentAbortDialog } from './PaymentAbortDialog'
import PaymentConfirmDialog from './PaymentConfirmDialog'
import { SendForm } from './SendForm'
@ -83,8 +85,9 @@ export const SendPage = ({ walletFileName }: SendPageProps) => {
waitForUtxosToBeSpent,
setWaitForUtxosToBeSpent,
} = useJamWalletInfoContext()
const jmSession = useStore(jmSessionStore, (state) => state.state)
const jmSessionActive = useStore(jmSessionStore, (state) => state.state?.session)
const {
makerInfo: { running: makerRunning },
takerInfo: {
running: takerRunning,
currentPaymentAttempt,
@ -204,14 +207,14 @@ export const SendPage = ({ walletFileName }: SendPageProps) => {
useRefreshSession({
enabled: isWaitingCoinjoinStart || isWaitingCoinjoinStop,
refetchInterval: 3_000,
refetchDelay: 1_000,
refetchInterval: JAM.WAIT_FOR_UPDATE_SESSION_POLLING_INTERVAL,
refetchDelay: JAM.WAIT_FOR_UPDATE_SESSION_POLLING_DELAY,
})
useRefreshSession({
enabled: takerRunning,
refetchInterval: 5_000,
refetchDelay: 1_000,
refetchInterval: JAM.RUNNING_COINJOIN_POLLING_INTERVAL,
refetchDelay: JAM.RUNNING_COINJOIN_POLLING_DELAY,
})
useEffect(() => {
@ -368,7 +371,7 @@ export const SendPage = ({ walletFileName }: SendPageProps) => {
await stopCoinjoinMutationMutateAsync()
}
if (!jmSession || walletInfoIsLoading) {
if (!jmSessionActive || walletInfoIsLoading) {
return <PageLoading />
}
@ -410,7 +413,7 @@ export const SendPage = ({ walletFileName }: SendPageProps) => {
{feeConfigValidation.maxFeesConfigMissing && (
<FeeConfigErrorAlert onOpenFeeConfig={() => setShowFeeConfigDialog(true)} className="mb-4" />
)}
{jmSession?.maker_running === true && (
{makerRunning === true && (
<Alert variant="warning">
<HourglassIcon className="motion-safe:animate-pulse" />
<AlertDescription>{t('send.text_maker_running')}</AlertDescription>
@ -446,102 +449,77 @@ export const SendPage = ({ walletFileName }: SendPageProps) => {
* If data `takerCurrentAttempt` is not present, no message is shown
* when the taker service stops - this is not ideal, but okay.
*/
currentPaymentAttempt !== undefined &&
currentPaymentAttempt.data.isCoinJoin &&
!isWaitingCoinjoinStart &&
takerRunning === false && (
<>
{walletInfoIsFetching ? (
<>
<Alert variant="default" className="motion-safe:animate-in blur-in my-2">
<Spinner className="motion-reduce:hidden" />
<AlertTitle>{t('send.alert_collaborative_awaiting_completion')}</AlertTitle>
currentPaymentAttempt?.data.isCoinJoin === true && !isWaitingCoinjoinStart && takerRunning === false && (
<>
{walletInfoIsFetching ? (
<>
<Alert variant="default" className="motion-safe:animate-in blur-in my-2">
<Spinner className="motion-reduce:hidden" />
<AlertTitle>{t('send.alert_collaborative_awaiting_completion')}</AlertTitle>
</Alert>
</>
) : (
<>
{currentPaymentAttempt.utxosHashHex === utxosHashHex ? (
<Alert variant="warning">
<AlertTriangleIcon />
<AlertTitle>{t('send.alert_collaborative_ended_title')}</AlertTitle>
<AlertDescription className="flex flex-col gap-2">
<div>{t('send.alert_collaborative_ended_description')}</div>
<div>
<Button
variant="outline"
onClick={() => {
clearCurrentPaymentAttempt()
}}
>
{t('global.done')}
</Button>
</div>
</AlertDescription>
</Alert>
</>
) : (
<>
{currentPaymentAttempt.utxosHashHex === utxosHashHex ? (
<Alert variant="warning">
<AlertTriangleIcon />
<AlertTitle>{t('send.alert_collaborative_ended_title')}</AlertTitle>
<AlertDescription className="flex flex-col gap-2">
<div>{t('send.alert_collaborative_ended_description')}</div>
<div>
<Button
variant="outline"
onClick={() => {
clearCurrentPaymentAttempt()
}}
>
{t('global.done')}
</Button>
</div>
</AlertDescription>
</Alert>
) : (
<Alert variant="success">
<CheckCircle2Icon />
<AlertTitle>{t('send.alert_collaborative_completed_title')}</AlertTitle>
<AlertDescription className="flex flex-col gap-2">
<div>{t('send.alert_collaborative_completed_description')}</div>
<div>
<Button
variant="outline"
onClick={() => {
setFormId((current) => current + 1)
clearCurrentPaymentAttempt()
}}
>
{t('global.done')}
</Button>
</div>
</AlertDescription>
</Alert>
)}
</>
)}
</>
)
) : (
<Alert variant="success">
<CheckCircle2Icon />
<AlertTitle>{t('send.alert_collaborative_completed_title')}</AlertTitle>
<AlertDescription className="flex flex-col gap-2">
<div>{t('send.alert_collaborative_completed_description')}</div>
<div>
<Button
variant="outline"
onClick={() => {
setFormId((current) => current + 1)
clearCurrentPaymentAttempt()
}}
>
{t('global.done')}
</Button>
</div>
</AlertDescription>
</Alert>
)}
</>
)}
</>
)
}
{takerRunning && !isWaitingCoinjoinStop && (
{schedulerRunning && (
<Alert variant="warning">
<HourglassIcon className="motion-safe:animate-pulse" />
<AlertTitle>{t('send.text_coinjoin_already_running')}</AlertTitle>
<AlertDescription className="flex flex-col gap-2">
{currentPaymentAttempt && (
<pre className="max-w-full overflow-x-auto">
{JSON.stringify(
{
sourceJar: currentPaymentAttempt.data.source.fromJar,
destinationJar: currentPaymentAttempt.data.destination.fromJar,
destinationAddress: currentPaymentAttempt.data.destination.address,
isSweep: currentPaymentAttempt.data.amount.isSweep === true,
amount:
currentPaymentAttempt.data.amount.isSweep === true
? currentPaymentAttempt.data.amount.sweepAmount
: currentPaymentAttempt.data.amount.amount,
numCollaborators: currentPaymentAttempt.data.numCollaborators,
},
null,
2,
)}
</pre>
)}
{takerRunning && !schedulerRunning ? (
<div>
<Button
variant="outline"
onClick={() => setShowAbortCoinjoinDialog(true)}
disabled={isWaitingCoinjoinStop}
>
{t('global.abort')}
</Button>
</div>
) : null}
</AlertDescription>
</Alert>
)}
{takerRunning && !schedulerRunning && (
<ActiveCollaborativeSendAlert
paymentAttempt={currentPaymentAttempt?.data.isCoinJoin === true ? currentPaymentAttempt : undefined}
jars={jars}
isAborting={isWaitingCoinjoinStop}
onAbort={() => setShowAbortCoinjoinDialog(true)}
/>
)}
{triggerNonCollaborativeTransaction.error ? (
<Alert variant="destructive">
<AlertTriangleIcon />
@ -586,7 +564,11 @@ export const SendPage = ({ walletFileName }: SendPageProps) => {
</>
)}
<Card>
<Card
className={cn('transition-all duration-500', {
'blur-[2px]': currentPaymentAttempt !== undefined || takerRunning || schedulerRunning || makerRunning,
})}
>
<CardContent>
<SendForm
key={formId}
@ -598,7 +580,7 @@ export const SendPage = ({ walletFileName }: SendPageProps) => {
addressSummary={addressSummary}
walletBalanceSummary={walletBalanceSummary}
disabled={
jmSession?.maker_running === true ||
makerRunning ||
takerRunning ||
rescanInfo.rescanning ||
isWaitingCoinjoinStart ||

View file

@ -1,43 +0,0 @@
import { GlobeIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import languages from '@/i18n/languages'
export const LanguageSelector = () => {
const { i18n, t } = useTranslation()
const handleLanguageChange = async (languageKey: string) => {
await i18n.changeLanguage(languageKey)
}
const getCurrentLanguageDescription = () => {
const currentLanguage = languages.find((lang) => lang.key === i18n.resolvedLanguage)
return currentLanguage?.description || 'English'
}
return (
<div className="flex flex-col items-stretch justify-between gap-2 py-2 sm:flex-row sm:items-center">
<div className="flex min-w-0 items-center gap-2">
<div className="bg-muted/50 flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border">
<GlobeIcon className="text-muted-foreground h-3 w-3" />
</div>
<div>
<p className="text-sm font-medium">{t('settings.label_select_language')}</p>
</div>
</div>
<Select value={i18n.resolvedLanguage} onValueChange={(value) => void handleLanguageChange(value)}>
<SelectTrigger className="h-7 w-full text-xs sm:w-38" aria-label="Select language">
<SelectValue placeholder={getCurrentLanguageDescription()} />
</SelectTrigger>
<SelectContent>
{languages.map((language) => (
<SelectItem key={language.key} value={language.key}>
{language.description}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}

View file

@ -3,6 +3,7 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { type RescanInfo } from '@/context/JamSessionInfoContext'
import { SEGWIT_ACTIVATION_BLOCK, type WalletFileName } from '@/lib/utils'
import { withRuntimeLocale } from '@/test/withRuntimeLocale'
import type { BlockHeight } from '@/types/global'
import { RescanChainPage } from './RescanChainPage'
@ -85,18 +86,20 @@ describe('RescanChainPage', () => {
})
it('renders the form', async () => {
await renderPage()
await withRuntimeLocale('en-US', async () => {
await renderPage()
expect(screen.getByText('rescan_chain.title')).toBeInTheDocument()
expect(screen.getByText('rescan_chain.title')).toBeInTheDocument()
expect(screen.getByRole('spinbutton', { name: 'rescan_chain.label_blockheight' })).toBeInTheDocument()
expect(screen.getByPlaceholderText('rescan_chain.placeholder_blockheight')).toBeInTheDocument()
expect(screen.getByRole('spinbutton', { name: 'rescan_chain.label_blockheight' })).toBeInTheDocument()
expect(screen.getByPlaceholderText('rescan_chain.placeholder_blockheight')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Last 144 blocks (~24 hours)' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Last 52,560 blocks (~1 year)' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'From block #481,824' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Last 144 blocks (~24 hours)' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Last 52,560 blocks (~1 year)' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'From block #481,824' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'rescan_chain.text_button_submit' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'rescan_chain.text_button_submit' })).toBeInTheDocument()
})
})
it('renders the form and navigates back', async () => {

View file

@ -1,7 +1,6 @@
import { useState, useEffect, useMemo, type ComponentProps } from 'react'
import { getseedOptions } from '@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { cx } from 'class-variance-authority'
import { AlertTriangleIcon, ClockIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
@ -17,7 +16,7 @@ import {
import { Label } from '@/components/ui/label'
import { useApiClient } from '@/hooks/useApiClient'
import { getErrorReason } from '@/lib/errorReason'
import type { WalletFileName } from '@/lib/utils'
import { cn, type WalletFileName } from '@/lib/utils'
import type { Milliseconds, WithRequiredProperty } from '@/types/global'
import { SeedPhraseGrid } from '../ui/jam/SeedPhraseGrid'
import { Spinner } from '../ui/spinner'
@ -189,13 +188,13 @@ export const SeedPhraseDialog = ({
<DialogFooter>
<div className="flex w-full items-center justify-between">
<div
className={cx('text-muted-foreground flex items-center gap-1 text-sm', {
className={cn('text-muted-foreground flex items-center gap-1 text-sm', {
'text-destructive! animate-pulse': secondsLeft <= 10,
})}
>
<ClockIcon className="h-4 w-4" />
<span
className={cx('mt-0.5 font-mono', {
className={cn('mt-0.5 font-mono', {
hidden: secondsLeft < 1,
})}
>

View file

@ -0,0 +1,183 @@
import { render, screen } from '@testing-library/react'
import user from '@testing-library/user-event'
import { KeyRoundIcon } from 'lucide-react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { SettingsItem, SettingsLink, SettingsSwitch } from './SettingsItem'
const mocks = vi.hoisted(() => ({
navigate: vi.fn(),
open: vi.fn(),
}))
vi.mock('react-router-dom', () => ({
useNavigate: () => mocks.navigate,
}))
vi.mock('@/components/ui/switch', () => ({
Switch: ({
checked,
disabled,
onCheckedChange,
}: {
checked?: boolean
disabled?: boolean
onCheckedChange?: (checked: boolean) => void
}) => (
<button type="button" disabled={disabled} onClick={() => onCheckedChange?.(!checked)}>
switch:{String(checked)}
</button>
),
}))
describe('SettingsItem', () => {
beforeEach(() => {
mocks.navigate.mockReset()
mocks.open.mockReset()
})
it('renders title and icon', () => {
render(<SettingsItem icon={KeyRoundIcon} title="settings.show_seed" />)
expect(screen.getByText('settings.show_seed')).toBeInTheDocument()
})
it('exposes an actionable row as a button', () => {
render(<SettingsItem icon={KeyRoundIcon} title="settings.show_seed" action={vi.fn()} />)
expect(screen.getByRole('button', { name: 'settings.show_seed' })).toBeInTheDocument()
})
it('does not render a button without an action', () => {
render(<SettingsItem icon={KeyRoundIcon} title="settings.show_seed" />)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('is reachable by keyboard', async () => {
render(<SettingsItem icon={KeyRoundIcon} title="settings.show_seed" action={vi.fn()} />)
await user.tab()
expect(screen.getByRole('button', { name: 'settings.show_seed' })).toHaveFocus()
})
it('triggers the action on Enter', async () => {
const action = vi.fn()
render(<SettingsItem icon={KeyRoundIcon} title="settings.show_seed" action={action} />)
await user.tab()
await user.keyboard('{Enter}')
expect(action).toHaveBeenCalledOnce()
})
it('triggers the action on Space', async () => {
const action = vi.fn()
render(<SettingsItem icon={KeyRoundIcon} title="settings.show_seed" action={action} />)
await user.tab()
await user.keyboard(' ')
expect(action).toHaveBeenCalledOnce()
})
it('triggers the action on click', async () => {
const action = vi.fn()
render(<SettingsItem icon={KeyRoundIcon} title="settings.show_seed" action={action} />)
await user.click(screen.getByRole('button', { name: 'settings.show_seed' }))
expect(action).toHaveBeenCalledOnce()
})
it('does not trigger the action while disabled', async () => {
const action = vi.fn()
render(<SettingsItem icon={KeyRoundIcon} title="settings.show_seed" action={action} disabled={true} />)
const item = screen.getByRole('button', { name: 'settings.show_seed' })
expect(item).toBeDisabled()
await user.click(item)
await user.tab()
await user.keyboard('{Enter}')
expect(action).not.toHaveBeenCalled()
})
it('stays a wrapper when children provide their own control', () => {
render(
<SettingsItem icon={KeyRoundIcon} title="settings.show_seed" action={vi.fn()} hasInteractiveChild={true}>
<button type="button">child-control</button>
</SettingsItem>,
)
// no button is nested inside another button
expect(screen.queryByRole('button', { name: 'settings.show_seed' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'child-control' })).toBeInTheDocument()
})
})
describe('SettingsLink', () => {
beforeEach(() => {
mocks.navigate.mockReset()
})
it('navigates internally on Enter', async () => {
render(<SettingsLink icon={KeyRoundIcon} title="settings.rescan_chain" to="/rescan" />)
await user.tab()
await user.keyboard('{Enter}')
expect(mocks.navigate).toHaveBeenCalledWith('/rescan')
})
it('opens external links in a new tab on Enter', async () => {
vi.stubGlobal('open', mocks.open)
render(<SettingsLink icon={KeyRoundIcon} title="settings.documentation" to="https://example.org" external={true} />)
await user.tab()
await user.keyboard('{Enter}')
expect(mocks.open).toHaveBeenCalledWith('https://example.org', '_blank', 'noreferrer,noopener')
expect(mocks.navigate).not.toHaveBeenCalled()
})
})
describe('SettingSwitch', () => {
it('is keyboard operable via the row when no toggle is displayed', async () => {
const onCheckedChange = vi.fn()
render(
<SettingsSwitch
icon={KeyRoundIcon}
title="settings.hide_balance"
checked={false}
onCheckedChange={onCheckedChange}
displayToggle={false}
/>,
)
await user.tab()
await user.keyboard('{Enter}')
expect(onCheckedChange).toHaveBeenCalledWith(true)
})
it('is keyboard operable via the toggle when one is displayed', async () => {
const onCheckedChange = vi.fn()
render(
<SettingsSwitch
icon={KeyRoundIcon}
title="settings.use_address_chunking_enabled"
checked={false}
onCheckedChange={onCheckedChange}
displayToggle={true}
/>,
)
await user.tab()
expect(screen.getByRole('button', { name: 'switch:false' })).toHaveFocus()
await user.keyboard('{Enter}')
expect(onCheckedChange).toHaveBeenCalledWith(true)
})
})

View file

@ -1,9 +1,9 @@
import type { PropsWithChildren, ReactNode } from 'react'
import { cx } from 'class-variance-authority'
import { ExternalLinkIcon } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { Switch } from '@/components/ui/switch'
import { cn } from '@/lib/utils'
type SettingsItemProps = PropsWithChildren<{
icon?: LucideIcon
@ -11,24 +11,37 @@ type SettingsItemProps = PropsWithChildren<{
title: string
disabled?: boolean
action?: () => void | Promise<void>
/**
* Set when {@link children} already renders its own focusable control (e.g. a `Switch`).
* The row then stays a plain wrapper instead of becoming a button, so a button is never
* nested inside a button - keyboard users operate the child control directly.
*/
hasInteractiveChild?: boolean
}>
export const SettingItem = ({
export const SettingsItem = ({
icon: Icon,
renderIcon,
title,
action,
disabled = false,
hasInteractiveChild = false,
children,
}: SettingsItemProps) => {
const rowClassName = cn('flex min-w-0 items-center justify-between gap-2 py-2', {
'hover:bg-muted/50 cursor-pointer': !disabled && action,
'rounded-md px-2': !disabled,
'cursor-not-allowed opacity-60': disabled,
})
// The row bleeds into the card padding so the hover background spans the full width.
// A block `div` with `width: auto` gets this from the negative margin alone, but a
// `button` does not stretch to its parent, and once `w-full` pins the width the
// negative margin no longer widens it. Keeping the bleed on a wrapper makes `w-full`
// resolve against the widened box, so both variants end up the same size.
const bleedClassName = !disabled ? '-mx-2' : undefined
const content = (
<div
className={cx('flex min-w-0 items-center justify-between gap-2 py-2', {
'hover:bg-muted/50 -mx-2 cursor-pointer rounded-md px-2': !disabled,
'cursor-not-allowed opacity-60': disabled,
})}
onClick={!disabled && action ? () => void action() : undefined}
>
<>
<div className="flex min-w-0 items-center gap-2">
<div className="bg-muted/50 flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border">
{Icon && <Icon className="text-muted-foreground h-4 w-4 align-middle" />}
@ -39,13 +52,34 @@ export const SettingItem = ({
</div>
</div>
{children !== undefined && <div className="shrink-0">{children}</div>}
</div>
</>
)
return content
// A row that is itself the control must be a real button: the browser then provides
// focus, Enter/Space activation and the correct role for free.
if (action && !hasInteractiveChild) {
return (
<div className={bleedClassName}>
<button
type="button"
className={cn(rowClassName, 'w-full appearance-none border-0 bg-transparent text-left')}
onClick={() => void action()}
disabled={disabled}
>
{content}
</button>
</div>
)
}
return (
<div className={cn(bleedClassName, rowClassName)} onClick={!disabled && action ? () => void action() : undefined}>
{content}
</div>
)
}
type SettingsLinkProps = Omit<SettingsItemProps, 'action' | 'children'> & {
type SettingsLinkProps = Omit<SettingsItemProps, 'action' | 'children' | 'hasInteractiveChild'> & {
to: string
external?: boolean
}
@ -54,7 +88,7 @@ export const SettingsLink = ({ to, external = false, ...props }: SettingsLinkPro
const navigate = useNavigate()
return (
<SettingItem
<SettingsItem
{...props}
action={async () => {
if (external) {
@ -65,19 +99,22 @@ export const SettingsLink = ({ to, external = false, ...props }: SettingsLinkPro
}}
>
{external && <ExternalLinkIcon className="text-muted-foreground size-3.5" />}
</SettingItem>
</SettingsItem>
)
}
type SettingsSwitchProps = Omit<SettingsItemProps, 'children' | 'action'> & {
type SettingsSwitchProps = Omit<SettingsItemProps, 'children' | 'action' | 'hasInteractiveChild'> & {
checked?: boolean
onCheckedChange?: (checked: boolean) => void
displayToggle?: boolean
}
export const SettingSwitch = ({ checked, onCheckedChange, displayToggle = true, ...props }: SettingsSwitchProps) => {
export const SettingsSwitch = ({ checked, onCheckedChange, displayToggle = true, ...props }: SettingsSwitchProps) => {
return (
<SettingItem {...props} action={() => onCheckedChange?.(!checked)}>
// With a visible toggle the `Switch` is the focusable control and the row stays a
// wrapper. Without one the row itself has to be the button, or the setting cannot be
// reached by keyboard at all.
<SettingsItem {...props} action={() => onCheckedChange?.(!checked)} hasInteractiveChild={displayToggle}>
{displayToggle && <Switch checked={checked} onCheckedChange={onCheckedChange} disabled={props.disabled} />}
</SettingItem>
</SettingsItem>
)
}

View file

@ -59,6 +59,9 @@ vi.mock('next-themes', () => ({
vi.mock('react-i18next', () => ({
useTranslation: () => ({
i18n: {
resolvedLanguage: 'en',
},
t: (key: string) => key,
}),
}))
@ -123,7 +126,7 @@ vi.mock('@/components/settings/AccountXpubsDialog', () => ({
) : null,
}))
vi.mock('@/components/settings/LanguageSelector', () => ({
vi.mock('@/components/ui/jam/LanguageSelector', () => ({
LanguageSelector: () => <div>language-selector</div>,
}))

View file

@ -16,6 +16,9 @@ import {
UnfoldHorizontalIcon,
KeyRoundIcon,
HandCoinsIcon,
SparklesIcon,
HistoryIcon,
LanguagesIcon,
} from 'lucide-react'
import { useTheme } from 'next-themes'
import { useTranslation } from 'react-i18next'
@ -23,8 +26,10 @@ import { useNavigate, type NavigateFunction } from 'react-router-dom'
import { useStore } from 'zustand'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { CurrencySymbol } from '@/components/ui/jam/CurrencySymbol'
import { LanguageSelector } from '@/components/ui/jam/LanguageSelector'
import PageTitle from '@/components/ui/jam/PageTitle'
import { Separator } from '@/components/ui/separator'
import { Spinner } from '@/components/ui/spinner'
import { isDebugFeatureEnabled, isDevMode } from '@/constants/debugFeatures'
import { JAM_DOCS_URL, JAM_MATRIX_URL, JAM_REPO_URL, JAM_SEED_MODAL_TIMEOUT, JAM_TELEGRAM_URL } from '@/constants/jam'
import { routes } from '@/constants/routes'
@ -34,11 +39,9 @@ import { useFeeConfigValidation } from '@/hooks/useFeeConfigValidation'
import { cn, type WalletFileName } from '@/lib/utils'
import { authStore } from '@/store/authStore'
import { jamSettingsStore } from '@/store/jamSettingsStore'
import { Spinner } from '../ui/spinner'
import { AccountXpubsDialog } from './AccountXpubsDialog'
import { LanguageSelector } from './LanguageSelector'
import { SeedPhraseDialog } from './SeedPhraseDialog'
import { SettingItem, SettingsLink, SettingSwitch } from './SettingsItem'
import { SettingsItem, SettingsLink, SettingsSwitch } from './SettingsItem'
import { FeeConfigDialog } from './fees/FeeConfigDialog'
interface SettingPageProps {
@ -79,7 +82,7 @@ export const SettingsPage = ({ walletFileName, onLockWallet }: SettingPageProps)
<CardTitle>{t('settings.section_title_display')}</CardTitle>
</CardHeader>
<CardContent>
<SettingSwitch
<SettingsSwitch
icon={isPrivate ? EyeIcon : EyeOffIcon}
title={t(isPrivate ? 'settings.show_balance' : 'settings.hide_balance')}
checked={isPrivate}
@ -87,7 +90,7 @@ export const SettingsPage = ({ walletFileName, onLockWallet }: SettingPageProps)
displayToggle={false}
/>
<Separator className="opacity-50" />
<SettingSwitch
<SettingsSwitch
renderIcon={({ className }) => <CurrencySymbol currency={currency} className={className} />}
title={t(currency === 'btc' ? 'settings.use_btc' : 'settings.use_sats')}
checked={currency === 'btc'}
@ -95,7 +98,7 @@ export const SettingsPage = ({ walletFileName, onLockWallet }: SettingPageProps)
displayToggle={false}
/>
<Separator className="opacity-50" />
<SettingSwitch
<SettingsSwitch
icon={addressChunkingEnabled === true ? UnfoldHorizontalIcon : FoldHorizontalIcon}
title={t(
addressChunkingEnabled === true
@ -107,7 +110,7 @@ export const SettingsPage = ({ walletFileName, onLockWallet }: SettingPageProps)
displayToggle={true}
/>
<Separator className="opacity-50" />
<SettingSwitch
<SettingsSwitch
icon={resolvedTheme === 'dark' ? SunIcon : MoonIcon}
title={resolvedTheme === 'dark' ? t('settings.use_light_theme') : t('settings.use_dark_theme')}
checked={resolvedTheme === 'dark'}
@ -115,7 +118,9 @@ export const SettingsPage = ({ walletFileName, onLockWallet }: SettingPageProps)
displayToggle={false}
/>
<Separator className="opacity-50" />
<LanguageSelector />
<SettingsItem icon={LanguagesIcon} title={t('settings.label_select_language')}>
<LanguageSelector />
</SettingsItem>
</CardContent>
</Card>
@ -125,7 +130,7 @@ export const SettingsPage = ({ walletFileName, onLockWallet }: SettingPageProps)
<CardTitle>{t('settings.section_title_market')}</CardTitle>
</CardHeader>
<CardContent>
<SettingItem
<SettingsItem
icon={HandCoinsIcon}
title={t('settings.show_fee_config')}
action={() => setShowFeeConfigDialog(true)}
@ -139,21 +144,21 @@ export const SettingsPage = ({ walletFileName, onLockWallet }: SettingPageProps)
<CardTitle>{t('settings.section_title_wallet')}</CardTitle>
</CardHeader>
<CardContent className="space-y-0">
<SettingItem
<SettingsItem
icon={KeyRoundIcon}
title={t('settings.show_seed')}
action={() => setShowSeedDialog(true)}
disabled={hashedPassword === undefined}
/>
<Separator className="opacity-50" />
<SettingItem
<SettingsItem
icon={BookKeyIcon}
title={t('settings.show_xpubs')}
action={() => setShowXpubsDialog(true)}
disabled={hashedPassword === undefined}
/>
<Separator className="opacity-50" />
<SettingItem
<SettingsItem
renderIcon={({ className }) =>
lockWalletMutation.isPending ? (
<Spinner className={className} />
@ -214,10 +219,39 @@ export const SettingsPage = ({ walletFileName, onLockWallet }: SettingPageProps)
to={JAM_REPO_URL}
external={true}
/>
<Separator className="opacity-50" />
<SettingsSwitch
icon={SparklesIcon}
title={/* TODO: i18n */ 'Enable Feature Preview'}
disabled={!isDevMode()}
checked={!!jamSettings.state.previewFeatures}
onCheckedChange={(checked) => {
jamSettings.update({ previewFeatures: checked ? {} : undefined })
}}
/>
{!!jamSettings.state.previewFeatures && (
<>
<Separator className="opacity-50" />
<SettingsSwitch
icon={HistoryIcon}
title={/* TODO: i18n */ 'Transaction History (Experimental)'}
disabled={!isDevMode()}
checked={!!jamSettings.state.previewFeatures?.['tx-history'] === true}
onCheckedChange={(checked) => {
jamSettings.update({
previewFeatures: {
...jamSettings.state.previewFeatures,
'tx-history': checked,
},
})
}}
/>
</>
)}
{isDevMode() && (
<>
<Separator className="opacity-50" />
<SettingSwitch
<SettingsSwitch
icon={TerminalIcon}
title="Enable developer mode"
disabled={!isDevMode()}

View file

@ -107,6 +107,19 @@ const mocks = vi.hoisted(() => ({
stopState: { isPending: false, isSuccess: false },
toastError: vi.fn(),
walletInfo: undefined as WalletInfo | undefined,
hasOrders: true,
orderbookIsLoading: false,
orderbookError: false,
}))
vi.mock('@/hooks/useQueryOrderbook', () => ({
useQueryOrderbook: () => ({
hasOrders: mocks.hasOrders,
queryResult: {
isLoading: mocks.orderbookIsLoading,
isError: mocks.orderbookError,
},
}),
}))
vi.mock('@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query', () => ({
@ -331,6 +344,7 @@ const makeWalletInfo = (overrides: Partial<WalletInfo> = {}): WalletInfo => {
detectedNetwork: null,
error: null,
fidelityBondSummary: { fbOutputs: [] },
hasEligibleFidelityBondUtxo: false,
isFetching: false,
isLoading: false,
jars: [jar, emptyJar(1), emptyJar(2)],
@ -403,6 +417,9 @@ describe('SweepPage', async () => {
mocks.stopState = { isPending: false, isSuccess: false }
mocks.toastError.mockReset()
mocks.walletInfo = makeWalletInfo()
mocks.hasOrders = true
mocks.orderbookIsLoading = false
mocks.orderbookError = false
setSession()
vi.clearAllMocks()
@ -721,4 +738,27 @@ describe('SweepPage', async () => {
await flushActUpdates()
})
})
describe('Orderbook Precheck', () => {
it('shows empty orderbook warning when orderbook has no offers', () => {
mocks.hasOrders = false
render(<SweepPage walletFileName="wallet.jmdat" />)
expect(screen.getByText('orderbook.alert_precheck_empty_title')).toBeInTheDocument()
})
it('does not show empty orderbook warning when orderbook is loading or has error', () => {
mocks.hasOrders = false
mocks.orderbookIsLoading = true
const { rerender } = render(<SweepPage walletFileName="wallet.jmdat" />)
expect(screen.queryByText('orderbook.alert_precheck_empty_title')).not.toBeInTheDocument()
mocks.orderbookIsLoading = false
mocks.orderbookError = true
rerender(<SweepPage walletFileName="wallet.jmdat" />)
expect(screen.queryByText('orderbook.alert_precheck_empty_title')).not.toBeInTheDocument()
})
})
})

View file

@ -23,20 +23,21 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { Balance } from '@/components/ui/jam/Balance'
import { FeeConfigErrorAlert } from '@/components/ui/jam/FeeConfigErrorAlert'
import { OrderbookEmptyAlert } from '@/components/ui/jam/OrderbookEmptyAlert'
import { PageLoading } from '@/components/ui/jam/PageLoading'
import PageTitle from '@/components/ui/jam/PageTitle'
import { isDevMode } from '@/constants/debugFeatures'
import * as JAM from '@/constants/jam'
import type { TumblerParameters } from '@/constants/jm'
import { useJamSessionInfoContext } from '@/context/JamSessionInfoContext'
import { useDetectNetwork, useJamWalletInfoContext } from '@/context/JamWalletInfoContext'
import { useApiClient } from '@/hooks/useApiClient'
import { useFeeConfigValidation } from '@/hooks/useFeeConfigValidation'
import { useQueryOrderbook } from '@/hooks/useQueryOrderbook'
import { useRefreshSession } from '@/hooks/useRefreshSession'
import { getErrorReason } from '@/lib/errorReason'
import { scrollToTop, type WalletFileName } from '@/lib/utils'
import { cn, scrollToTop, type WalletFileName } from '@/lib/utils'
import { useDeveloperMode } from '@/store/jamSettingsStore'
import { jmSessionStore } from '@/store/jmSessionStore'
import type { Milliseconds } from '@/types/global'
import { Button } from '../ui/button'
import { Spinner } from '../ui/spinner'
import { SweepForm } from './SweepForm'
@ -46,11 +47,6 @@ interface SweepPageProps {
walletFileName: WalletFileName
}
const WAIT_FOR_UPDATE_SESSION_POLLING_INTERVAL: Milliseconds = 3_000
const WAIT_FOR_UPDATE_SESSION_POLLING_DELAY: Milliseconds = 1_000
const RUNNING_SCHEDULE_POLLING_INTERVAL: Milliseconds = isDevMode() ? 5_000 : 10_000
const INSECURE_SCHEDULE_TUMBLER_OPTIONS: Partial<TumblerParameters> = {
time_lambda_seconds: 10,
stage1_wait_multiplier: 1.5,
@ -62,7 +58,14 @@ const INSECURE_SCHEDULE_TUMBLER_OPTIONS: Partial<TumblerParameters> = {
export const SweepPage = ({ walletFileName }: SweepPageProps) => {
const { t } = useTranslation()
const client = useApiClient()
const { rescanInfo, takerInfo, makerInfo } = useJamSessionInfoContext()
const {
rescanInfo,
makerInfo: { running: makerRunning },
takerInfo: {
running: takerRunning,
scheduler: { running: schedulerRunning },
},
} = useJamSessionInfoContext()
const jmSession = useStore(jmSessionStore, (state) => state.state)
const walletInfo = useJamWalletInfoContext()
const { network } = useDetectNetwork()
@ -71,6 +74,15 @@ export const SweepPage = ({ walletFileName }: SweepPageProps) => {
const [showFeeConfigDialog, setShowFeeConfigDialog] = useState(false)
const [showScheduleConfirmDialog, setShowScheduleConfirmDialog] = useState<TumblerPlanRequest>()
const [alertMessage, setAlertMessage] = useState<string>()
const {
hasOrders,
queryResult: {
isLoading: orderbookCheckIsLoading,
isError: orderbookCheckIsError,
isFetching: orderbookCheckIsFetching,
refetch: orderbookRefetch,
},
} = useQueryOrderbook()
const feeConfigValidation = useFeeConfigValidation({ walletFileName })
@ -85,9 +97,8 @@ export const SweepPage = ({ walletFileName }: SweepPageProps) => {
path: { walletname: walletFileName },
}),
refetchInterval: (query) => {
const schedulerRunning = takerInfo.running && takerInfo.scheduler.running
const currentScheduleStillActive = query.state.data && !isPlanTerminated(query.state.data)
return schedulerRunning || currentScheduleStillActive ? RUNNING_SCHEDULE_POLLING_INTERVAL : false
return schedulerRunning || currentScheduleStillActive ? JAM.RUNNING_SCHEDULE_POLLING_INTERVAL : false
},
refetchIntervalInBackground: true,
retry: false,
@ -195,21 +206,18 @@ export const SweepPage = ({ walletFileName }: SweepPageProps) => {
return toSchedule(getScheduleQuery.data, walletInfo.jars)
}, [getScheduleQuery.error, getScheduleQuery.data, walletInfo.jars])
const schedulerRunning = takerInfo.scheduler.running
const isWaitingSchedulerStart =
startScheduleMutationIsPending || (startScheduleMutationIsSuccess && !schedulerRunning)
const waitingForTumblerStatus = takerInfo.running && getScheduleQuery.isPending
const waitingForTumblerStatus = takerRunning && getScheduleQuery.isPending
const singleCoinJoinRunning =
takerInfo.running && !schedulerRunning && !isWaitingSchedulerStart && !waitingForTumblerStatus
const makerRunning = makerInfo.running === true
const collaborativeOperationRunning = makerRunning || takerInfo.running
takerRunning && !schedulerRunning && !isWaitingSchedulerStart && !waitingForTumblerStatus
const isWaitingSchedulerStop = stopScheduleMutationIsPending || (stopScheduleMutationIsSuccess && schedulerRunning)
useRefreshSession({
enabled: isWaitingSchedulerStart || isWaitingSchedulerStop,
refetchInterval: WAIT_FOR_UPDATE_SESSION_POLLING_INTERVAL,
refetchDelay: WAIT_FOR_UPDATE_SESSION_POLLING_DELAY,
refetchInterval: JAM.WAIT_FOR_UPDATE_SESSION_POLLING_INTERVAL,
refetchDelay: JAM.WAIT_FOR_UPDATE_SESSION_POLLING_DELAY,
})
useEffect(() => {
@ -226,8 +234,10 @@ export const SweepPage = ({ walletFileName }: SweepPageProps) => {
const isOperationDisabled =
feeConfigValidation.maxFeesConfigMissing ||
collaborativeOperationRunning ||
makerRunning ||
takerRunning ||
rescanInfo.rescanning ||
makerRunning ||
!preconditionSummary.isFulfilled
const isStartDisabled =
@ -285,6 +295,10 @@ export const SweepPage = ({ walletFileName }: SweepPageProps) => {
<FeeConfigErrorAlert onOpenFeeConfig={() => setShowFeeConfigDialog(true)} className="mb-4" />
)}
{!orderbookCheckIsLoading && !orderbookCheckIsError && !hasOrders && (
<OrderbookEmptyAlert isChecking={orderbookCheckIsFetching} onCheckClick={orderbookRefetch} />
)}
{alertMessage && (
<Alert variant="destructive">
<AlertTitle>{t('global.error')}</AlertTitle>
@ -295,14 +309,14 @@ export const SweepPage = ({ walletFileName }: SweepPageProps) => {
{singleCoinJoinRunning && (
<Alert variant="warning">
<HourglassIcon className="motion-safe:animate-pulse" />
<AlertDescription>{t('send.text_coinjoin_already_running')}</AlertDescription>
<AlertTitle>{t('send.text_coinjoin_already_running')}</AlertTitle>
</Alert>
)}
{makerRunning && (
<Alert variant="warning">
<HourglassIcon className="motion-safe:animate-pulse" />
<AlertDescription>{t('send.text_maker_running')}</AlertDescription>
<AlertTitle>{t('send.text_maker_running')}</AlertTitle>
</Alert>
)}
@ -436,7 +450,11 @@ export const SweepPage = ({ walletFileName }: SweepPageProps) => {
{!currentSchedule && !schedulerRunning && (
<>
<Card>
<Card
className={cn('transition-all duration-500', {
'blur-[2px]': takerRunning || makerRunning,
})}
>
<CardContent className="space-y-5">
<div className="bg-muted/50 flex flex-col items-start justify-between gap-2 rounded-lg border px-4 py-3 sm:flex-row sm:items-center">
<div className="min-w-0">

View file

@ -1,97 +1,10 @@
import { AlertTriangleIcon } from 'lucide-react'
import { Trans, useTranslation } from 'react-i18next'
import { Link } from 'react-router-dom'
import type { SweepPreconditionSummary } from '@/components/sweep/preconditions'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Balance } from '@/components/ui/jam/Balance'
import { JAM_JM_RETRIES_DOCS_URL } from '@/constants/jam'
import { routes } from '@/constants/routes'
import { shortenStringMiddle } from '@/lib/utils'
import { JarBadge } from '../earn/fidelity-bond/FidelityBondDialogParts'
import { PreconditionAlert } from '@/components/ui/jam/PreconditionAlert'
interface SweepPreconditionAlertProps {
summary: SweepPreconditionSummary
}
const RetryLockedUtxoList = ({ summary }: SweepPreconditionAlertProps) => {
const { t } = useTranslation()
return (
<>
<Trans
i18nKey="scheduler.precondition.hint_missing_retries"
components={{
'1': (
<a
key="retries-doc-link"
className="font-semibold"
href={JAM_JM_RETRIES_DOCS_URL}
target="_blank"
rel="noopener noreferrer"
/>
),
}}
/>
<div className="mt-3 p-2">
<div className="mb-2 text-xs font-semibold">{t('global.utxos')}</div>
<ul className="list-inside list-[circle] space-y-2">
{summary.retryLockedUtxos.map((utxo) => (
<li key={utxo.utxo} className="space-x-2">
<span className="slashed-zero tabular-nums">
<JarBadge jarIndex={utxo.mixdepth} />
</span>
<span className="font-mono select-all">{shortenStringMiddle(utxo.utxo, 26)}</span>
<Balance valueString={String(utxo.value)} />
</li>
))}
</ul>
</div>
</>
)
}
export const SweepPreconditionAlert = ({ summary }: SweepPreconditionAlertProps) => {
const { t } = useTranslation()
if (summary.isFulfilled) {
return null
}
return (
<Alert variant="warning">
<AlertTriangleIcon />
<AlertTitle>{t('scheduler.precondition.title')}</AlertTitle>
<AlertDescription>
<ul className="list-outside list-disc space-y-2">
{summary.numberOfNonFrozenFidelityBondOutputs === 0 ? null : (
<li>
<Trans
i18nKey="scheduler.precondition.hint_non_frozen_fidelity_bond"
values={{ count: summary.numberOfNonFrozenFidelityBondOutputs }}
components={{
'1': <Link to={routes.walletJarsDetails} className="font-semibold" />,
}}
/>
</li>
)}
{summary.numberOfMissingUtxos === 0 ? null : (
<li>
{t('scheduler.precondition.hint_missing_utxos', {
minConfirmations: summary.options.minConfirmations,
})}
</li>
)}
{summary.numberOfMissingConfirmations === 0 ? null : (
<li>
{t('scheduler.precondition.hint_missing_confirmations', {
minConfirmations: summary.options.minConfirmations,
amountOfMissingConfirmations: summary.numberOfMissingConfirmations,
})}
</li>
)}
{summary.retryLockedUtxos.length === 0 ? null : <li>{<RetryLockedUtxoList summary={summary} />}</li>}
</ul>
</AlertDescription>
</Alert>
)
return <PreconditionAlert summary={summary} i18nPrefix="scheduler.precondition" />
}

View file

@ -40,6 +40,7 @@ const buttonVariants = cva(
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
'icon-sm': 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
'icon-lg': 'size-9',
'icon-xxl': 'size-12',
},
},
defaultVariants: {

View file

@ -5,6 +5,8 @@ import user from '@testing-library/user-event'
import { describe, it, expect, vi } from 'vitest'
import { Balance } from '@/components/ui/jam/Balance'
import { JamDisplayContextProvider } from '@/context/JamDisplayContextProvider'
import '@/i18n/config'
import { withRuntimeLocale } from '@/test/withRuntimeLocale'
const render = (ui: React.ReactNode, options?: Omit<RenderOptions, 'queries'>) => {
const providers = ({ children }: { children: React.ReactNode }) => {
@ -35,11 +37,13 @@ describe('<Balance />', () => {
})
it('should render balance in SATS', () => {
render(<Balance valueString={'123.456'} convertToUnit="sats" showBalance={true} />)
expect(screen.getByTestId('sats-amount')).toHaveTextContent(`12,345,600,000`)
expect(screen.getByTestId('sats-symbol')).toBeVisible()
expect(screen.queryByTestId('bitcoin-symbol')).not.toBeInTheDocument()
expect(screen.queryByTestId('frozen-symbol')).not.toBeInTheDocument()
withRuntimeLocale('en-US', () => {
render(<Balance valueString={'123.456'} convertToUnit="sats" showBalance={true} />)
expect(screen.getByTestId('sats-amount')).toHaveTextContent(`12,345,600,000`)
expect(screen.getByTestId('sats-symbol')).toBeVisible()
expect(screen.queryByTestId('bitcoin-symbol')).not.toBeInTheDocument()
expect(screen.queryByTestId('frozen-symbol')).not.toBeInTheDocument()
})
})
it('should render a string BTC value correctly as BTC', () => {
@ -49,9 +53,11 @@ describe('<Balance />', () => {
})
it('should render a string BTC value correctly as SATS', () => {
render(<Balance valueString={'123.03224961'} convertToUnit="sats" showBalance={true} />)
expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`12,303,224,961`)
expect(screen.getByTestId('sats-symbol')).toBeVisible()
withRuntimeLocale('en-US', () => {
render(<Balance valueString={'123.03224961'} convertToUnit="sats" showBalance={true} />)
expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`12,303,224,961`)
expect(screen.getByTestId('sats-symbol')).toBeVisible()
})
})
it('should render a zero string BTC value correctly as BTC', () => {
@ -65,23 +71,31 @@ describe('<Balance />', () => {
})
it('should render a large string BTC value correctly as BTC', () => {
render(<Balance valueString={'20999999.97690000'} convertToUnit="btc" showBalance={true} />)
expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`20,999,999.97690000`)
withRuntimeLocale('en-US', () => {
render(<Balance valueString={'20999999.97690000'} convertToUnit="btc" showBalance={true} />)
expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`20,999,999.97690000`)
})
})
it('should render a large string BTC value correctly as SATS', () => {
render(<Balance valueString={'20999999.97690000'} convertToUnit="sats" showBalance={true} />)
expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`2,099,999,997,690,000`)
withRuntimeLocale('en-US', () => {
render(<Balance valueString={'20999999.97690000'} convertToUnit="sats" showBalance={true} />)
expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`2,099,999,997,690,000`)
})
})
it('should render a max string BTC value correctly as BTC', () => {
render(<Balance valueString={'21000000.00000000'} convertToUnit="btc" showBalance={true} />)
expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`21,000,000.00000000`)
withRuntimeLocale('en-US', () => {
render(<Balance valueString={'21000000.00000000'} convertToUnit="btc" showBalance={true} />)
expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`21,000,000.00000000`)
})
})
it('should render a max string BTC value correctly as SATS', () => {
render(<Balance valueString={'21000000.00000000'} convertToUnit="sats" showBalance={true} />)
expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`2,100,000,000,000,000`)
withRuntimeLocale('en-US', () => {
render(<Balance valueString={'21000000.00000000'} convertToUnit="sats" showBalance={true} />)
expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`2,100,000,000,000,000`)
})
})
it('should render a string SATS value correctly as SATS', () => {
@ -105,23 +119,31 @@ describe('<Balance />', () => {
})
it('should render a large string SATS value correctly as BTC', () => {
render(<Balance valueString={'2099999997690000'} convertToUnit="btc" showBalance={true} />)
expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`20,999,999.97690000`)
withRuntimeLocale('en-US', () => {
render(<Balance valueString={'2099999997690000'} convertToUnit="btc" showBalance={true} />)
expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`20,999,999.97690000`)
})
})
it('should render a large string SATS value correctly as SATS', () => {
render(<Balance valueString={'2099999997690000'} convertToUnit="sats" showBalance={true} />)
expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`2,099,999,997,690,000`)
withRuntimeLocale('en-US', () => {
render(<Balance valueString={'2099999997690000'} convertToUnit="sats" showBalance={true} />)
expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`2,099,999,997,690,000`)
})
})
it('should render a max string SATS value correctly as BTC', () => {
render(<Balance valueString={'2100000000000000'} convertToUnit="btc" showBalance={true} />)
expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`21,000,000.00000000`)
withRuntimeLocale('en-US', () => {
render(<Balance valueString={'2100000000000000'} convertToUnit="btc" showBalance={true} />)
expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`21,000,000.00000000`)
})
})
it('should render a max string SATS value correctly as SATS', () => {
render(<Balance valueString={'2100000000000000'} convertToUnit="sats" showBalance={true} />)
expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`2,100,000,000,000,000`)
withRuntimeLocale('en-US', () => {
render(<Balance valueString={'2100000000000000'} convertToUnit="sats" showBalance={true} />)
expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`2,100,000,000,000,000`)
})
})
it('should render frozen balance in BTC', () => {
@ -132,10 +154,12 @@ describe('<Balance />', () => {
})
it('should render frozen balance in SATS', () => {
render(<Balance valueString={'123.456'} convertToUnit="sats" showBalance={true} frozen={true} />)
expect(screen.getByTestId('sats-amount')).toHaveTextContent(`12,345,600,000`)
expect(screen.getByTestId('sats-symbol')).toBeVisible()
expect(screen.getByTestId('frozen-symbol')).toBeVisible()
withRuntimeLocale('en-US', () => {
render(<Balance valueString={'123.456'} convertToUnit="sats" showBalance={true} frozen={true} />)
expect(screen.getByTestId('sats-amount')).toHaveTextContent(`12,345,600,000`)
expect(screen.getByTestId('sats-symbol')).toBeVisible()
expect(screen.getByTestId('frozen-symbol')).toBeVisible()
})
})
it('should render balance without symbol', () => {
@ -198,4 +222,51 @@ describe('<Balance />', () => {
expect(screen.getByTestId(`sats-amount`)).toBeInTheDocument()
expect(screen.queryByText(`*****`)).not.toBeInTheDocument()
})
it('should render the visibility toggle as a keyboard-focusable button with an accessible name', () => {
render(<Balance valueString={`21`} convertToUnit="sats" showBalance={false} />)
const toggleButton = screen.getByRole('button', { name: 'Show balance' })
expect(toggleButton).toBeInTheDocument()
expect(toggleButton).toHaveAttribute('aria-pressed', 'true')
})
it('should update the accessible name and aria-pressed after toggling', async () => {
render(<Balance valueString={`21`} convertToUnit="sats" showBalance={false} />)
await user.click(screen.getByRole('button', { name: 'Show balance' }))
const toggleButton = screen.getByRole('button', { name: 'Hide balance' })
expect(toggleButton).toBeInTheDocument()
expect(toggleButton).toHaveAttribute('aria-pressed', 'false')
})
it('should toggle visibility via keyboard (Enter)', async () => {
render(<Balance valueString={`21`} convertToUnit="sats" showBalance={false} />)
await user.tab()
expect(screen.getByRole('button', { name: 'Show balance' })).toHaveFocus()
await user.keyboard('{Enter}')
expect(screen.getByTestId(`sats-amount`)).toBeInTheDocument()
expect(screen.queryByText(`*****`)).not.toBeInTheDocument()
})
it('should toggle visibility via keyboard (Space)', async () => {
render(<Balance valueString={`21`} convertToUnit="sats" showBalance={false} />)
await user.tab()
expect(screen.getByRole('button', { name: 'Show balance' })).toHaveFocus()
await user.keyboard(' ')
expect(screen.getByTestId(`sats-amount`)).toBeInTheDocument()
expect(screen.queryByText(`*****`)).not.toBeInTheDocument()
})
it('should not render a focusable button when the visibility toggle is disabled', () => {
render(<Balance valueString={`21`} convertToUnit="sats" showBalance={false} enableVisibilityToggle={false} />)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
})

View file

@ -1,5 +1,6 @@
import { useEffect, useMemo, useState, type MouseEvent, type MouseEventHandler, type PropsWithChildren } from 'react'
import { SnowflakeIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { CurrencySymbol } from '@/components/ui/jam/CurrencySymbol'
import { useJamDisplayContext } from '@/context/JamDisplayContext'
import { cn, satsToBtc, tryBtcToSat, isValidNumber, getBtcParts, formatSats } from '@/lib/utils'
@ -23,6 +24,8 @@ type ElementWithSymbolsProps = PropsWithChildren<{
frozenSymbol?: boolean
className?: string
onClick?: MouseEventHandler
'aria-label'?: string
'aria-pressed'?: boolean
}>
const ElementWithSymbols = ({
@ -33,23 +36,40 @@ const ElementWithSymbols = ({
className,
children,
onClick,
'aria-label': ariaLabel,
'aria-pressed': ariaPressed,
}: ElementWithSymbolsProps) => {
return (
<span
className={cn(
'balance-hook inline-flex items-center',
{
'text-brand-info': frozen,
},
className,
)}
onClick={onClick}
>
const sharedClassName = cn(
'balance-hook inline-flex items-center',
{
'text-brand-info': frozen,
},
className,
)
const content = (
<>
{children}
{showSymbol && symbol}
{frozen && frozenSymbol && FROZEN_SYMBOL}
</span>
</>
)
if (onClick) {
return (
<button
type="button"
className={cn(sharedClassName, 'appearance-none border-0 bg-transparent p-0')}
onClick={onClick}
aria-label={ariaLabel}
aria-pressed={ariaPressed}
>
{content}
</button>
)
}
return <span className={sharedClassName}>{content}</span>
}
const DECIMAL_POINT_CHAR = '.'
@ -156,6 +176,7 @@ export const BalanceComponent = ({
enableVisibilityToggle,
...props
}: BalanceComponentProps) => {
const { t } = useTranslation()
const [isBalanceVisible, setIsBalanceVisible] = useState(showBalance)
const displayMode = useMemo<DisplayMode>(() => {
return isBalanceVisible ? (convertToUnit ?? 'default') : 'hidden'
@ -173,18 +194,25 @@ export const BalanceComponent = ({
setIsBalanceVisible((current) => !current)
}
const onClickHandler = enableVisibilityToggle === false ? undefined : toggleVisibility
const isInteractive = Boolean(onClickHandler || props.onClick)
return {
...props,
className: cn(props.className, {
'cursor-pointer': onClickHandler || props.onClick,
'cursor-pointer': isInteractive,
}),
onClick: (event: MouseEvent<HTMLSpanElement>) => {
onClickHandler?.(event)
props.onClick?.(event)
},
onClick: isInteractive
? (event: MouseEvent<HTMLButtonElement>) => {
onClickHandler?.(event)
props.onClick?.(event)
}
: undefined,
'aria-label': onClickHandler
? t(isBalanceVisible ? 'settings.hide_balance' : 'settings.show_balance')
: undefined,
'aria-pressed': onClickHandler ? !isBalanceVisible : undefined,
}
}, [props, enableVisibilityToggle])
}, [props, enableVisibilityToggle, isBalanceVisible, t])
const element = useMemo(() => {
if (displayMode === 'hidden') {

View file

@ -0,0 +1,163 @@
import { jarBadgeVariant } from '@/components/ui/badge-variants'
import { cn } from '@/lib/utils'
export interface CookingPotIconProps {
sourceJarIndex?: number
destinationJarIndex?: number
className?: string
}
const STROKE_CLASSES: Record<string, string> = {
jar0: 'stroke-jar0',
jar1: 'stroke-jar1',
jar2: 'stroke-jar2',
jar3: 'stroke-jar3',
jar4: 'stroke-jar4',
jarUnknown: 'stroke-jar-unknown',
}
export const CookingPotIcon = ({ sourceJarIndex, destinationJarIndex, className }: CookingPotIconProps) => {
const sourceVariant = jarBadgeVariant(sourceJarIndex)
const destinationVariant = jarBadgeVariant(destinationJarIndex) ?? sourceVariant
const sourceStrokeClass = (sourceVariant && STROKE_CLASSES[sourceVariant]) || 'stroke-jar-unknown'
const destinationStrokeClass = (destinationVariant && STROKE_CLASSES[destinationVariant]) || sourceStrokeClass
return (
<div className={cn('relative flex flex-col items-center justify-center select-none', className)}>
<style>{`
@keyframes lidRattle {
0%, 100% { transform: rotate(0deg); }
20% { transform: rotate(-2.5deg) translateY(-1px); }
40% { transform: rotate(1deg); }
60% { transform: rotate(-1.5deg) translateY(-0.5px); }
80% { transform: rotate(0.5deg); }
}
@keyframes waveShift {
0%, 100% { transform: translateX(0); }
50% { transform: translateX(3px); }
}
@keyframes steamRise {
0% { transform: translateY(0) scaleY(0.9); opacity: 0.15; }
50% { opacity: 0.75; }
100% { transform: translateY(-12px) scaleY(1.1); opacity: 0; }
}
.anim-lid {
animation: lidRattle 2.2s ease-in-out infinite;
transform-origin: 35px 50px;
}
.anim-wave {
animation: waveShift 2.8s ease-in-out infinite;
}
.anim-steam-1 { animation: steamRise 2.2s ease-in-out infinite; }
.anim-steam-2 { animation: steamRise 2.6s ease-in-out 0.7s infinite; }
.anim-steam-3 { animation: steamRise 2.4s ease-in-out 1.2s infinite; }
`}</style>
<div className="relative z-10 flex size-44 items-center justify-center md:size-48">
<svg viewBox="0 0 200 160" className="size-full select-none" aria-hidden="true">
<defs>
<pattern
id="potDiagonalHatch"
width="10"
height="10"
patternUnits="userSpaceOnUse"
patternTransform="rotate(45)"
>
<line x1="0" y1="0" x2="0" y2="10" className={sourceStrokeClass} strokeWidth="2.5" />
<line x1="5" y1="0" x2="5" y2="10" className={destinationStrokeClass} strokeWidth="2.5" />
</pattern>
<clipPath id="potBodyClip">
<path d="M 35 50 V 122 C 35 132 43 140 53 140 H 147 C 157 140 165 132 165 122 V 50 Z" />
</clipPath>
</defs>
<g className="text-foreground">
<g className="text-foreground/60">
<path
d="M 125 35 Q 118 24 126 14 T 120 2"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
className="anim-steam-1"
/>
<path
d="M 148 30 Q 142 20 150 10 T 144 -2"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
className="anim-steam-2"
/>
<path
d="M 102 42 Q 96 30 104 18 T 98 5"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
className="anim-steam-3"
/>
</g>
<path
d="M 35 60 H 22 C 14 60 14 80 22 80 H 35"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M 165 60 H 178 C 186 60 186 80 178 80 H 165"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<g clipPath="url(#potBodyClip)">
<g className="anim-wave">
<path
d="M 25 78 C 55 58 75 70 100 88 C 120 102 145 106 175 96 L 175 150 L 25 150 Z"
fill="url(#potDiagonalHatch)"
/>
</g>
</g>
<path
d="M 35 50 V 122 C 35 132 43 140 53 140 H 147 C 157 140 165 132 165 122 V 50 Z"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<g className="anim-lid">
<path
d="M 87 27 C 87 20 107 20 107 27"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
/>
<path
d="M 35 50 C 31 40 38 34 44 34 L 158 20 C 164 19 168 24 165 30 L 35 50"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
</g>
</svg>
</div>
</div>
)
}

View file

@ -38,6 +38,8 @@ vi.mock('@/components/ui/select', () => ({
SelectTrigger: ({ children }: ChildrenProps) => <div>{children}</div>,
SelectValue: ({ placeholder }: { placeholder?: string }) => <div data-testid="select-value">{placeholder}</div>,
SelectContent: ({ children }: ChildrenProps) => <div>{children}</div>,
SelectGroup: ({ children }: ChildrenProps) => <div>{children}</div>,
SelectLabel: ({ children }: ChildrenProps) => <div>{children}</div>,
SelectItem: ({ children, value }: ChildrenProps & { value: string }) => (
<div data-testid={`select-item-${value}`}>{children}</div>
),

View file

@ -0,0 +1,116 @@
import type { ComponentProps, PropsWithChildren } from 'react'
import { LanguagesIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
SelectGroup,
SelectLabel,
} from '@/components/ui/select'
import languages from '@/i18n/languages'
import { cn } from '@/lib/utils'
import { Button } from '../button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from '../dropdown-menu'
export const LanguageSelector = ({
className,
...props
}: ComponentProps<typeof Select> & { className?: string } = {}) => {
const { i18n, t } = useTranslation()
const currentLanguage = languages.find((lang) => lang.key === i18n.resolvedLanguage)
const currentLanguageDescription = currentLanguage?.description || 'English'
return (
<Select
value={i18n.resolvedLanguage}
onValueChange={(value) => {
void i18n.changeLanguage(value)
}}
{...props}
>
<SelectTrigger
className={cn('w-full text-xs sm:min-w-42', className)}
aria-label={t('settings.label_select_language_aria_label')}
>
<SelectValue placeholder={currentLanguageDescription} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel className="flex items-center gap-1">
<LanguagesIcon className="size-4" />
{t('settings.label_select_language')}
</SelectLabel>
{languages.map((language) => (
<SelectItem key={language.key} value={language.key}>
{language.description}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
)
}
export const LanguageSelectorDropdownMenu = ({
className,
align,
loop = true,
...props
}: PropsWithChildren<
ComponentProps<typeof DropdownMenu> & {
className?: string
align?: ComponentProps<typeof DropdownMenuContent>['align']
loop?: ComponentProps<typeof DropdownMenuContent>['loop']
}
> = {}) => {
const { i18n, t } = useTranslation()
return (
<DropdownMenu {...props}>
<DropdownMenuTrigger asChild>
{props.children ?? (
<Button
type="button"
variant="ghost"
className={className}
aria-label={t('settings.label_select_language_aria_label')}
>
<LanguagesIcon />
{t('settings.label_select_language')}
</Button>
)}
</DropdownMenuTrigger>
<DropdownMenuContent className="w-full text-xs sm:min-w-56" align={align} loop={loop}>
<DropdownMenuGroup>
<DropdownMenuLabel className="flex items-center gap-1">
<LanguagesIcon className="size-4" />
{t('settings.label_select_language')}
</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={i18n.resolvedLanguage}
onValueChange={(value) => {
void i18n.changeLanguage(value)
}}
>
{languages.map((language) => (
<DropdownMenuRadioItem key={language.key} value={language.key}>
{language.description}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}

View file

@ -0,0 +1,46 @@
import type { ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { OrderbookEmptyAlert } from './OrderbookEmptyAlert'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
Trans: ({ i18nKey, components }: { i18nKey: string; components?: Record<string, ReactNode> }) => (
<span>
{i18nKey}
{components?.['1']}
</span>
),
}))
vi.mock('react-router-dom', () => ({
Link: ({ children, to }: { children?: ReactNode; to: string }) => <a href={to}>{children}</a>,
}))
describe('OrderbookEmptyAlert', () => {
it('renders title, description, button and link to orderbook page', () => {
render(<OrderbookEmptyAlert isChecking={false} onCheckClick={async () => {}} />)
expect(screen.getByText('orderbook.alert_precheck_empty_title')).toBeInTheDocument()
expect(screen.getByText(/orderbook.alert_precheck_empty_description/u)).toBeInTheDocument()
expect(screen.getByRole('link')).toHaveAttribute('href', '/orderbook')
const actionCheck = screen.getByRole('button', { name: 'orderbook.alert_precheck_empty_text_button_check' })
expect(actionCheck).toBeEnabled()
const actionChecking = screen.queryByRole('button', { name: 'orderbook.alert_precheck_empty_text_button_checking' })
expect(actionChecking).not.toBeInTheDocument()
})
it('disables button while checking', () => {
render(<OrderbookEmptyAlert isChecking={true} onCheckClick={async () => {}} />)
const actionChecking = screen.getByRole('button', { name: 'orderbook.alert_precheck_empty_text_button_checking' })
expect(actionChecking).toBeDisabled()
const actionCheck = screen.queryByRole('button', { name: 'orderbook.alert_precheck_empty_text_button_check' })
expect(actionCheck).not.toBeInTheDocument()
})
})

View file

@ -0,0 +1,43 @@
import { AlertTriangleIcon, RefreshCwIcon } from 'lucide-react'
import { Trans, useTranslation } from 'react-i18next'
import { Link } from 'react-router-dom'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { routes } from '@/constants/routes'
import { Button } from '../button'
interface OrderbookEmptyAlertProps {
className?: string
isChecking?: boolean
onCheckClick?: () => Promise<unknown>
}
export const OrderbookEmptyAlert = ({ className, isChecking, onCheckClick }: OrderbookEmptyAlertProps) => {
const { t } = useTranslation()
return (
<Alert variant="warning" className={className}>
<AlertTriangleIcon />
<AlertTitle>{t('orderbook.alert_precheck_empty_title')}</AlertTitle>
<AlertDescription>
<p>
<Trans
i18nKey="orderbook.alert_precheck_empty_description"
components={{
'1': <Link to={routes.orderbook} className="font-semibold" />,
}}
/>
</p>
<div className="flex items-center gap-2">
{onCheckClick && (
<Button size="xs" type="button" onClick={() => void onCheckClick()} disabled={isChecking}>
<RefreshCwIcon className={isChecking ? 'motion-safe:animate-spin' : undefined} />
{isChecking
? t('orderbook.alert_precheck_empty_text_button_checking')
: t('orderbook.alert_precheck_empty_text_button_check')}
</Button>
)}
</div>
</AlertDescription>
</Alert>
)
}

View file

@ -0,0 +1,102 @@
import { AlertTriangleIcon } from 'lucide-react'
import { Trans, useTranslation } from 'react-i18next'
import { Link } from 'react-router-dom'
import { JarBadge } from '@/components/earn/fidelity-bond/FidelityBondDialogParts'
import type { SweepPreconditionSummary } from '@/components/sweep/preconditions'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Balance } from '@/components/ui/jam/Balance'
import { JAM_JM_RETRIES_DOCS_URL } from '@/constants/jam'
import { routes } from '@/constants/routes'
import { shortenStringMiddle } from '@/lib/utils'
export interface PreconditionAlertProps {
summary: SweepPreconditionSummary
i18nPrefix: 'send.coinjoin_precondition' | 'scheduler.precondition'
}
const RetryLockedUtxoList = ({ summary, i18nPrefix }: PreconditionAlertProps) => {
const { t } = useTranslation()
return (
<>
<Trans
i18nKey={`${i18nPrefix}.hint_missing_retries`}
components={{
'1': (
<a
key="retries-doc-link"
className="font-semibold"
href={JAM_JM_RETRIES_DOCS_URL}
target="_blank"
rel="noopener noreferrer"
/>
),
}}
/>
<div className="mt-3 p-2">
<div className="mb-2 text-xs font-semibold">{t('global.utxos')}</div>
<ul className="list-inside list-[circle] space-y-2">
{summary.retryLockedUtxos.map((utxo) => (
<li key={utxo.utxo} className="space-x-2">
<span className="slashed-zero tabular-nums">
<JarBadge jarIndex={utxo.mixdepth} />
</span>
<span className="font-mono select-all">{shortenStringMiddle(utxo.utxo, 26)}</span>
<Balance valueString={String(utxo.value)} />
</li>
))}
</ul>
</div>
</>
)
}
export const PreconditionAlert = ({ summary, i18nPrefix }: PreconditionAlertProps) => {
const { t } = useTranslation()
if (summary.isFulfilled) {
return null
}
return (
<Alert variant="warning">
<AlertTriangleIcon />
<AlertTitle>{t(`${i18nPrefix}.title`)}</AlertTitle>
<AlertDescription>
<ul className="list-outside list-disc space-y-2">
{summary.numberOfNonFrozenFidelityBondOutputs === 0 ? null : (
<li>
<Trans
i18nKey={`${i18nPrefix}.hint_non_frozen_fidelity_bond`}
values={{ count: summary.numberOfNonFrozenFidelityBondOutputs }}
components={{
'1': <Link to={routes.walletJarsDetails} className="font-semibold" />,
}}
/>
</li>
)}
{summary.numberOfMissingUtxos === 0 ? null : (
<li>
{t(`${i18nPrefix}.hint_missing_utxos`, {
minConfirmations: summary.options.minConfirmations,
})}
</li>
)}
{summary.numberOfMissingConfirmations === 0 ? null : (
<li>
{t(`${i18nPrefix}.hint_missing_confirmations`, {
minConfirmations: summary.options.minConfirmations,
amountOfMissingConfirmations: summary.numberOfMissingConfirmations,
})}
</li>
)}
{summary.retryLockedUtxos.length === 0 ? null : (
<li>
<RetryLockedUtxoList summary={summary} i18nPrefix={i18nPrefix} />
</li>
)}
</ul>
</AlertDescription>
</Alert>
)
}

View file

@ -0,0 +1,113 @@
import type { WalletHistoryResponse } from '@joinmarket-webui/joinmarket-ng-api-ts/jm'
import type { UseQueryResult } from '@tanstack/react-query'
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { useQueryWalletHistory } from '@/hooks/useQueryWalletHistory'
import { TxHistoryContent } from './TxHistoryContent'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/hooks/useQueryWalletHistory', () => ({
useQueryWalletHistory: vi.fn(),
}))
vi.mock('./TxHistoryTable', () => ({
TxHistoryTable: ({ history, compact }: { history: unknown[]; compact: boolean }) => (
<div data-testid="mock-table" data-compact={compact}>
Mock Table (Rows: {history.length})
</div>
),
}))
vi.mock('@/components/ui/alert', () => ({
Alert: ({ children, variant }: { children: React.ReactNode; variant?: string }) => (
<div data-testid="mock-alert" data-variant={variant}>
{children}
</div>
),
AlertDescription: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
vi.mock('@/components/ui/spinner', () => ({
Spinner: () => <div data-testid="mock-loading" />,
}))
const mockUseQuery = useQueryWalletHistory as unknown as ReturnType<typeof vi.fn>
const mockQueryResult = (overrides: Partial<UseQueryResult<WalletHistoryResponse, unknown>>) =>
({
isLoading: false,
error: null,
isFetching: false,
refetch: vi.fn(),
...overrides,
}) as UseQueryResult<WalletHistoryResponse, unknown>
describe('TxHistoryContent', () => {
it('renders loading state', () => {
mockUseQuery.mockReturnValue({
history: [],
queryResult: mockQueryResult({ isLoading: true }),
})
render(<TxHistoryContent walletFileName="wallet.jmdat" compact={false} />)
expect(screen.getByTestId('mock-loading')).toBeInTheDocument()
})
it('renders error state', () => {
mockUseQuery.mockReturnValue({
history: [],
queryResult: mockQueryResult({ error: new Error('failed') }),
})
render(<TxHistoryContent walletFileName="wallet.jmdat" compact={false} />)
expect(screen.getByTestId('mock-alert')).toBeInTheDocument()
expect(screen.getByText('tx_history.error_loading')).toBeInTheDocument()
})
it('renders the table with data', () => {
const mockHistory = [{ txid: '1' }, { txid: '2' }]
mockUseQuery.mockReturnValue({
history: mockHistory,
queryResult: mockQueryResult({}),
})
render(<TxHistoryContent walletFileName="wallet.jmdat" compact={true} />)
expect(screen.getByTestId('mock-table')).toHaveAttribute('data-compact', 'true')
expect(screen.getByText('Mock Table (Rows: 2)')).toBeInTheDocument()
})
it('renders Load More button when not compact and handles clicks', () => {
const mockHistory = Array.from({ length: 10 }, () => ({ txid: 'mock' }))
const refetch = vi.fn()
mockUseQuery.mockReturnValue({
history: mockHistory,
queryResult: mockQueryResult({ refetch, isFetching: false }),
})
render(<TxHistoryContent walletFileName="wallet.jmdat" compact={false} />)
const loadMoreButton = screen.getByRole('button', { name: 'tx_history.button_load_more' })
expect(loadMoreButton).toBeInTheDocument()
fireEvent.click(loadMoreButton)
expect(mockUseQuery).toHaveBeenLastCalledWith(expect.objectContaining({ limit: 20 }))
})
it('hides Load More button if history length is less than limit', () => {
const mockHistory = Array.from({ length: 5 }, () => ({ txid: 'mock' }))
mockUseQuery.mockReturnValue({
history: mockHistory,
queryResult: mockQueryResult({}),
})
render(<TxHistoryContent walletFileName="wallet.jmdat" compact={false} />)
expect(screen.queryByRole('button', { name: 'tx_history.button_load_more' })).not.toBeInTheDocument()
})
})

View file

@ -0,0 +1,132 @@
import { useMemo, useState } from 'react'
import { AlertTriangleIcon, ListIcon, PlusIcon, RefreshCwIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { DevBadge } from '@/components/dev/DevBadge'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/spinner'
import { useJamWalletInfoContext } from '@/context/JamWalletInfoContext'
import { useQueryWalletHistory, type HistoryEntry } from '@/hooks/useQueryWalletHistory'
import { cn, type WalletFileName } from '@/lib/utils'
import { useDeveloperMode } from '@/store/jamSettingsStore'
import { TxHistoryTable } from './TxHistoryTable'
interface TxHistoryContentProps {
walletFileName: WalletFileName
className?: string
initialLimit?: number
compact?: boolean
enabled?: boolean
onViewAll?: () => void
}
const useSafeUtxosHashHex = () => {
try {
const context = useJamWalletInfoContext()
return context?.utxosHashHex
} catch {
return undefined
}
}
export const TxHistoryContent = ({
walletFileName,
className,
initialLimit = 10,
compact = false,
enabled = true,
onViewAll,
}: TxHistoryContentProps) => {
const { t } = useTranslation()
const [limit, setLimit] = useState(initialLimit)
const { enabled: isDeveloperMode } = useDeveloperMode()
const [demoEntries, setDemoEntries] = useState<HistoryEntry[]>([])
const utxosHashHex = useSafeUtxosHashHex()
const { history, queryResult } = useQueryWalletHistory({
walletFileName,
limit,
enabled,
utxosHashHex,
})
const combinedHistory = useMemo(() => [...demoEntries, ...history], [demoEntries, history])
return (
<section className={cn('flex flex-col gap-3', className)}>
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-muted-foreground text-sm font-light tracking-wide">{t('tx_history.section_title')}</h2>
<div className="flex flex-wrap items-center gap-2">
{isDeveloperMode && (
<Button
className="min-w-0 justify-center text-xs"
variant="outline"
size="sm"
onClick={() => {
const demoEntry: HistoryEntry = {
timestamp: new Date().toISOString(),
txid: `demo_${Date.now()}`,
role: 'send',
cj_amount: 100_000,
net_fee: -500,
confirmations: 6,
}
setDemoEntries((previous) => [demoEntry, ...previous])
}}
disabled={queryResult.isFetching}
>
<PlusIcon className="size-3" />
<span className="truncate">{t('tx_history.button_generate_demo_entry')}</span>
<DevBadge className="shrink-0" />
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => void queryResult.refetch()}
disabled={queryResult.isFetching}
>
<RefreshCwIcon className={cn({ 'motion-safe:animate-spin': queryResult.isFetching })} />
<span className="truncate">{t('tx_history.button_reload_title')}</span>
</Button>
{compact && onViewAll ? (
<Button size="sm" variant="outline" onClick={onViewAll}>
<ListIcon />
{t('tx_history.button_view_all')}
</Button>
) : null}
</div>
</div>
{queryResult.isLoading ? (
<div className="flex min-h-32 items-center justify-center gap-2">
<Spinner className="motion-reduce:hidden" />
{t('global.loading')}
</div>
) : queryResult.error ? (
<Alert variant="destructive">
<AlertTriangleIcon />
<AlertDescription>{t('tx_history.error_loading')}</AlertDescription>
</Alert>
) : (
<TxHistoryTable history={compact ? combinedHistory.slice(0, 5) : combinedHistory} compact={compact} />
)}
{!compact && combinedHistory.length >= limit ? (
<div className="flex justify-center">
<Button
type="button"
variant="outline"
onClick={() => setLimit((current) => current + 10)}
disabled={queryResult.isFetching}
>
{queryResult.isFetching ? <Spinner /> : undefined}
{t('tx_history.button_load_more')}
</Button>
</div>
) : null}
</section>
)
}

View file

@ -0,0 +1,66 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { TxHistoryOverlay } from './TxHistoryOverlay'
type ChildrenProps = { children: ReactNode }
type DialogProps = ChildrenProps & { open?: boolean; onOpenChange?: (open: boolean) => void }
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('./TxHistoryContent', () => ({
TxHistoryContent: ({ walletFileName }: { walletFileName: string }) => (
<div data-testid="tx-history-content" data-wallet={walletFileName}>
Content
</div>
),
}))
vi.mock('../ui/jam/PageTitle', () => ({
default: ({ title }: { title: string }) => <h1 data-testid="page-title">{title}</h1>,
}))
// Mock Dialog to avoid dealing with portals and radix UI internals
vi.mock('@/components/ui/dialog', () => ({
Dialog: ({ children, open, onOpenChange }: DialogProps) =>
open ? (
<div data-testid="dialog">
<button onClick={() => onOpenChange?.(false)}>Close</button>
{children}
</div>
) : null,
DialogContent: ({ children }: ChildrenProps) => <div>{children}</div>,
DialogHeader: ({ children }: ChildrenProps) => <div>{children}</div>,
DialogTitle: ({ children }: ChildrenProps) => <div>{children}</div>,
}))
describe('TxHistoryOverlay', () => {
it('renders dialog content when open', () => {
render(<TxHistoryOverlay open={true} onOpenChange={vi.fn()} walletFileName="test-wallet.jmdat" />)
expect(screen.getByTestId('page-title')).toHaveTextContent('tx_history.overlay_title')
const content = screen.getByTestId('tx-history-content')
expect(content).toBeInTheDocument()
expect(content).toHaveAttribute('data-wallet', 'test-wallet.jmdat')
})
it('calls onOpenChange when closed', () => {
const onOpenChange = vi.fn()
render(<TxHistoryOverlay open={true} onOpenChange={onOpenChange} walletFileName="test-wallet.jmdat" />)
const closeButton = screen.getByText('Close')
fireEvent.click(closeButton)
expect(onOpenChange).toHaveBeenCalledWith(false)
})
it('does not render when closed', () => {
render(<TxHistoryOverlay open={false} onOpenChange={vi.fn()} walletFileName="test-wallet.jmdat" />)
expect(screen.queryByTestId('dialog')).not.toBeInTheDocument()
})
})

View file

@ -0,0 +1,36 @@
import type { ComponentProps } from 'react'
import { useTranslation } from 'react-i18next'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import type { WithRequiredProperty } from '@/types/global'
import PageTitle from '../ui/jam/PageTitle'
import { TxHistoryContent } from './TxHistoryContent'
type TxHistoryOverlayProps = WithRequiredProperty<
Omit<ComponentProps<typeof Dialog>, 'children'>,
'open' | 'onOpenChange'
> &
Pick<ComponentProps<typeof TxHistoryContent>, 'walletFileName'>
export function TxHistoryOverlay({ open, onOpenChange, walletFileName, ...dialogProps }: TxHistoryOverlayProps) {
const { t } = useTranslation()
return (
<Dialog open={open} onOpenChange={() => onOpenChange(false)} {...dialogProps}>
<DialogContent className="data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom flex h-dvh max-h-dvh! max-w-screen! flex-col overflow-hidden rounded-none border-none">
<DialogHeader className="px-2">
<DialogTitle className="flex items-center gap-2">
<PageTitle title={t('tx_history.overlay_title')} />
</DialogTitle>
</DialogHeader>
<div className="flex min-h-0 flex-1 overflow-hidden">
<TxHistoryContent
enabled={open}
walletFileName={walletFileName}
className="flex h-full min-h-0 flex-1 flex-col overflow-hidden p-2 pt-0"
/>
</div>
</DialogContent>
</Dialog>
)
}

View file

@ -0,0 +1,26 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { TxHistoryPage } from './TxHistoryPage'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('./TxHistoryContent', () => ({
TxHistoryContent: ({ compact }: { compact: boolean }) => (
<div data-testid="tx-history-content" data-compact={compact} />
),
}))
describe('TxHistoryPage', () => {
it('renders TxHistoryContent in non-compact page mode with title', () => {
render(<TxHistoryPage walletFileName="wallet.jmdat" />)
expect(screen.getByText('tx_history.title')).toBeInTheDocument()
const content = screen.getByTestId('tx-history-content')
expect(content).toBeInTheDocument()
expect(content).toHaveAttribute('data-compact', 'false')
})
})

View file

@ -0,0 +1,19 @@
import { useTranslation } from 'react-i18next'
import PageTitle from '@/components/ui/jam/PageTitle'
import type { WalletFileName } from '@/lib/utils'
import { TxHistoryContent } from './TxHistoryContent'
interface TxHistoryPageProps {
walletFileName: WalletFileName
}
export function TxHistoryPage({ walletFileName }: TxHistoryPageProps) {
const { t } = useTranslation()
return (
<div className="mx-auto flex min-h-0 w-full max-w-6xl flex-1 flex-col space-y-4 p-4 sm:p-6">
<PageTitle title={t('tx_history.title')} />
<TxHistoryContent walletFileName={walletFileName} compact={false} />
</div>
)
}

View file

@ -0,0 +1,108 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { HistoryEntry } from '@/hooks/useQueryWalletHistory'
import { TxHistoryTable } from './TxHistoryTable'
const mocks = vi.hoisted(() => ({
toastSuccess: vi.fn(),
toastError: vi.fn(),
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('sonner', () => ({
toast: {
success: mocks.toastSuccess,
error: mocks.toastError,
},
}))
vi.mock('../ui/jam/Address', () => ({
Address: ({ value }: { value: string }) => <span>{value}</span>,
}))
vi.mock('../ui/jam/Balance', () => ({
Balance: ({ valueString }: { valueString: string }) => <span>{valueString}</span>,
}))
const mockDeposit: HistoryEntry = {
timestamp: '2026-07-28T12:00:00Z',
role: 'deposit',
txid: 'tx1',
cj_amount: 5000,
net_fee: 0,
confirmations: 10,
success: true,
source_mixdepth: 0,
}
const mockSend: HistoryEntry = {
timestamp: '2026-07-28T13:00:00Z',
role: 'send',
txid: 'tx2',
cj_amount: 10000,
net_fee: -100,
confirmations: 0,
success: true,
source_mixdepth: 0,
}
const mockFailed: HistoryEntry = {
timestamp: '2026-07-28T14:00:00Z',
role: 'taker',
txid: 'tx3',
cj_amount: 0,
net_fee: 0,
confirmations: 0,
success: false,
failure_reason: 'aborted',
source_mixdepth: 1,
}
describe('TxHistoryTable', () => {
beforeEach(() => {
mocks.toastSuccess.mockReset()
mocks.toastError.mockReset()
})
it('renders history rows and sorts by date', () => {
render(<TxHistoryTable history={[mockDeposit, mockSend]} />)
expect(screen.getByText('tx_history.label_role_deposit')).toBeInTheDocument()
expect(screen.getByText('tx_history.label_role_send')).toBeInTheDocument()
const dateHeader = screen.getByText('tx_history.column_title_date')
fireEvent.click(dateHeader)
expect(dateHeader).toBeInTheDocument()
})
it('renders empty history alert', () => {
render(<TxHistoryTable history={[]} />)
expect(screen.getByText('tx_history.empty_history')).toBeInTheDocument()
})
it('expands row details to show raw JSON', () => {
render(<TxHistoryTable history={[mockDeposit]} />)
const detailsButton = screen.getByRole('button', { name: 'jar_details.utxo_list.row_button_details' })
fireEvent.click(detailsButton)
// Check if the JSON is rendered
expect(screen.getByText(/"txid": "tx1"/u)).toBeInTheDocument()
})
it('sorts columns correctly', () => {
render(<TxHistoryTable history={[mockDeposit, mockSend, mockFailed]} />)
fireEvent.click(screen.getByText('tx_history.column_title_amount'))
fireEvent.click(screen.getByText('tx_history.column_title_net_fee'))
fireEvent.click(screen.getByText('tx_history.column_title_confirmations'))
fireEvent.click(screen.getByText('tx_history.column_title_role'))
expect(screen.getByText('tx_history.label_role_taker')).toBeInTheDocument()
})
})

View file

@ -0,0 +1,317 @@
import { useEffect, useMemo, useState } from 'react'
import {
createColumnHelper,
flexRender,
getCoreRowModel,
getExpandedRowModel,
getPaginationRowModel,
getSortedRowModel,
type CellContext,
type PaginationState,
type Row,
type SortingState,
useReactTable,
} from '@tanstack/react-table'
import type { TFunction } from 'i18next'
import { CheckIcon, ChevronDownIcon, CopyIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { TablePagination } from '@/components/ui/jam/TablePagination'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import type { HistoryEntry } from '@/hooks/useQueryWalletHistory'
import { cn, shortenStringMiddle } from '@/lib/utils'
import { Alert, AlertDescription } from '../ui/alert'
import { Button } from '../ui/button'
import { buttonVariants } from '../ui/button-variants'
import { Card, CardContent } from '../ui/card'
import { Balance } from '../ui/jam/Balance'
import { CopyButton } from '../ui/jam/CopyButton'
import { SortIcon } from '../ui/jam/SortIcon'
import { StatusBadge } from '../ui/jam/StatusBadge'
const ITEMS_PER_PAGE = 25
type KnownHistoryRole = 'maker' | 'taker' | 'send' | 'deposit'
type StatusBadgeVariant = NonNullable<Parameters<typeof StatusBadge>[0]['variant']>
const ROLE_VARIANT: Record<KnownHistoryRole, StatusBadgeVariant> = {
maker: 'cj-out',
taker: 'cj-change',
send: 'used-empty',
deposit: 'deposit',
}
const columnHelper = createColumnHelper<HistoryEntry>()
const historyRole = (entry: HistoryEntry): KnownHistoryRole | undefined => {
return ['maker', 'taker', 'send', 'deposit'].includes(entry.role ?? '') ? (entry.role as KnownHistoryRole) : undefined
}
const roleLabel = (entry: HistoryEntry, t: TFunction) => {
const role = historyRole(entry)
return role ? t(`tx_history.label_role_${role}`) : entry.role || ''
}
const dateTimeValue = (value: string) => {
const date = new Date(value)
return Number.isNaN(date.getTime()) ? 0 : date.getTime()
}
const formatDateTime = (value: string) => {
const date = new Date(value)
return Number.isNaN(date.getTime()) ? value : date.toLocaleString()
}
const TxHistoryDetails = ({ entry }: { entry: HistoryEntry }) => {
return (
<Card>
<CardContent className="overflow-x-auto p-4 font-mono text-xs">
<pre>{JSON.stringify(entry, null, 2)}</pre>
</CardContent>
</Card>
)
}
const TxHistoryTableRow = ({ row }: { row: Row<HistoryEntry> }) => {
return (
<>
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => {
const alignCenter = cell.column.columnDef.meta?.align === 'center'
const alignRight = cell.column.columnDef.meta?.align === 'right'
return (
<TableCell
key={cell.id}
className={cn({
'text-center': alignCenter,
'text-right': alignRight,
})}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
)
})}
</TableRow>
{row.getIsExpanded() && (
<TableRow>
<TableCell colSpan={row.getAllCells().length} className="p-2">
<TxHistoryDetails entry={row.original} />
</TableCell>
</TableRow>
)}
</>
)
}
const txHistoryTableColumns = (t: TFunction) => {
return [
columnHelper.accessor('timestamp', {
header: () => t('tx_history.column_title_date'),
sortingFn: (a, b) => dateTimeValue(a.original.timestamp) - dateTimeValue(b.original.timestamp),
cell: (info) => <span className="text-nowrap">{formatDateTime(info.getValue())}</span>,
meta: {
alphabetic: true,
},
}),
columnHelper.accessor('role', {
header: () => t('tx_history.column_title_role'),
sortingFn: (a, b) => roleLabel(a.original, t).localeCompare(roleLabel(b.original, t)),
cell: (info) => {
const role = historyRole(info.row.original)
return (
<StatusBadge variant={role ? ROLE_VARIANT[role] : 'default'}>{roleLabel(info.row.original, t)}</StatusBadge>
)
},
}),
columnHelper.accessor('cj_amount', {
header: () => t('tx_history.column_title_amount'),
sortingFn: (a, b) => (a.original.cj_amount ?? 0) - (b.original.cj_amount ?? 0),
cell: (info) => <Balance valueString={String(info.getValue() ?? 0)} />,
meta: {
numeric: true,
align: 'right',
},
}),
columnHelper.accessor('net_fee', {
header: () => t('tx_history.column_title_net_fee'),
sortingFn: (a, b) => (a.original.net_fee ?? 0) - (b.original.net_fee ?? 0),
cell: (info) => <Balance valueString={String(info.getValue() ?? 0)} />,
meta: {
numeric: true,
align: 'right',
},
}),
columnHelper.accessor('confirmations', {
header: () => t('tx_history.column_title_confirmations'),
sortingFn: (a, b) => (a.original.confirmations ?? 0) - (b.original.confirmations ?? 0),
cell: (info) => <>{info.getValue() ?? 0}</>,
meta: {
numeric: true,
align: 'center',
},
}),
columnHelper.accessor('txid', {
header: () => t('tx_history.column_title_txid'),
cell: (info) => {
const txid = info.getValue() ?? ''
return txid ? (
<div className="flex min-w-0 items-center gap-2">
<span className="min-w-0 font-mono text-xs slashed-zero select-all">{shortenStringMiddle(txid, 16)}</span>
<CopyButton
value={txid}
text={<CopyIcon />}
successText={<CheckIcon className="text-brand-success" />}
className={cn(buttonVariants({ variant: 'outline', size: 'icon-xs' }), 'shrink-0')}
onSuccess={() => toast.success(t('tx_history.copy_txid_success'))}
onError={() => toast.error(t('tx_history.copy_txid_error'))}
/>
</div>
) : (
''
)
},
enableSorting: false,
}),
{
id: 'expand-col',
cell: ({ row }: CellContext<HistoryEntry, unknown>) => {
return row.getCanExpand() ? (
<Button size="sm" variant="outline" onClick={row.getToggleExpandedHandler()}>
{t('jar_details.utxo_list.row_button_details')}
<ChevronDownIcon
className={cn('text-muted-foreground size-4 transition-transform duration-200', {
'rotate-180': row.getIsExpanded(),
})}
/>
</Button>
) : (
''
)
},
enableSorting: false,
},
]
}
interface TxHistoryTableProps {
history: HistoryEntry[]
compact?: boolean
}
export const TxHistoryTable = ({ history, compact = false }: TxHistoryTableProps) => {
const { t } = useTranslation()
const [sorting, setSorting] = useState<SortingState>([{ id: 'timestamp', desc: true }])
const [isShowAll, setIsShowAll] = useState(false)
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: compact ? 5 : ITEMS_PER_PAGE,
})
useEffect(() => {
if (isShowAll) {
setPagination((previous) => ({
...previous,
pageSize: Math.max(1, history.length),
pageIndex: 0,
}))
}
}, [history.length, isShowAll])
const columns = useMemo(() => txHistoryTableColumns(t), [t])
const table = useReactTable<HistoryEntry>({
data: history,
columns,
state: {
sorting,
pagination,
},
autoResetPageIndex: true,
getRowId: (row, index) => `${row.txid || 'notxid'}-${row.source_mixdepth ?? 0}-${row.timestamp}-${index}`,
onSortingChange: setSorting,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getRowCanExpand: () => true,
getExpandedRowModel: getExpandedRowModel(),
paginateExpandedRows: false,
})
if (history.length === 0) {
return (
<Alert>
<AlertDescription>{t('tx_history.empty_history')}</AlertDescription>
</Alert>
)
}
return (
<div className="flex flex-1 flex-col gap-2 overflow-hidden rounded-lg border shadow-lg">
<div className="flex-1 overflow-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const canSort = header.column.getCanSort()
const alignCenter = header.column.columnDef.meta?.align === 'center'
const alignRight = header.column.columnDef.meta?.align === 'right'
return (
<TableHead
key={header.id}
className={cn({
'cursor-pointer select-none': canSort,
'text-center': alignCenter,
'text-right': alignRight,
})}
onClick={canSort ? () => header.column.toggleSorting() : undefined}
>
<div
className={cn('flex items-center gap-2', {
'cursor-pointer select-none': canSort,
'justify-center': alignCenter,
'justify-end': alignRight,
'font-bold': header.column.getIsSorted(),
'text-muted-foreground': table.getState().sorting.length > 0 && !header.column.getIsSorted(),
})}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{canSort ? <SortIcon className="size-4" column={header.column} /> : undefined}
</div>
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody className=":bg-foreground [&>tr:nth-child(odd)]:bg-foreground/10 [&>tr]:hover:bg-foreground/20!">
{table.getRowModel().rows.map((row) => (
<TxHistoryTableRow key={row.id} row={row} />
))}
</TableBody>
</Table>
</div>
{!compact && (
<TablePagination
currentPage={table.getState().pagination.pageIndex + 1}
totalPages={table.getPageCount()}
itemsPerPage={isShowAll ? -1 : pagination.pageSize}
totalItems={table.getFilteredRowModel().rows.length}
onPageChange={(page) => table.setPageIndex(page - 1)}
onItemsPerPageChange={(newItemsPerPage) => {
if (newItemsPerPage === -1) {
setIsShowAll(true)
table.setPageSize(history.length || 1)
} else {
setIsShowAll(false)
table.setPageSize(newItemsPerPage)
}
table.setPageIndex(0)
}}
/>
)}
</div>
)
}

View file

@ -78,6 +78,54 @@ export const JAM_RESCAN_PROGRESS_INTERVAL: Milliseconds = Math.max(
JAM_RESCAN_PROGRESS_MIN_INTERVAL,
)
export const WAIT_FOR_UPDATE_SESSION_POLLING_INTERVAL: Milliseconds = Math.max(
parseAsIntOrDefault(import.meta.env.VITE_JAM_WAIT_FOR_UPDATE_SESSION_POLLING_INTERVAL, 3_000),
1_000,
)
export const WAIT_FOR_UPDATE_SESSION_POLLING_DELAY: Milliseconds = Math.max(
parseAsIntOrDefault(import.meta.env.VITE_JAM_WAIT_FOR_UPDATE_SESSION_POLLING_DELAY, 1_000),
1,
)
export const WAIT_FOR_UPDATE_ORDERBOOK_POLLING_INTERVAL: Milliseconds = Math.max(
parseAsIntOrDefault(import.meta.env.VITE_JAM_WAIT_FOR_UPDATE_ORDERBOOK_POLLING_INTERVAL, 15_000),
1_000,
)
export const VISIBLE_ORDERBOOK_POLLING_INTERVAL: Milliseconds = Math.max(
parseAsIntOrDefault(import.meta.env.VITE_JAM_VISIBLE_ORDERBOOK_POLLING_INTERVAL, 120_000),
1_000,
)
export const RUNNING_COINJOIN_POLLING_INTERVAL: Milliseconds = Math.max(
parseAsIntOrDefault(import.meta.env.VITE_JAM_RUNNING_COINJOIN_POLLING_INTERVAL, 5_000),
1_000,
)
export const RUNNING_COINJOIN_POLLING_DELAY: Milliseconds = Math.max(
parseAsIntOrDefault(import.meta.env.VITE_JAM_RUNNING_COINJOIN_POLLING_DELAY, 1_000),
1,
)
export const RUNNING_SCHEDULE_POLLING_INTERVAL: Milliseconds = Math.max(
parseAsIntOrDefault(import.meta.env.VITE_JAM_RUNNING_SCHEDULE_POLLING_INTERVAL, 10_000),
1_000,
)
/**
* In order to prevent state mismatch, the 'maker stop' response is delayed shortly.
* Even though the API response suggests that the maker has started or stopped immediately, it seems that this is not always the case.
* There is currently no way to know for sure - adding a delay at least mitigates the problem.
* 2022-04-26: With value of 2_000ms, no state corruption could be provoked in a local dev setup.
*/
export const MAKER_STOP_RESPONSE_DELAY: Milliseconds = Math.max(
parseAsIntOrDefault(import.meta.env.VITE_JAM_MAKER_STOP_RESPONSE_DELAY, 2_000),
1,
)
export const JMWALLETD_LOGS_POLLING_INTERVAL: Milliseconds = Math.max(
parseAsIntOrDefault(import.meta.env.VITE_JAM_JMWALLETD_LOGS_POLLING_INTERVAL, 2_500),
500,
)
const JAM_SEED_MODAL_MIN_TIMEOUT: Milliseconds = 5_000
const JAM_SEED_MODAL_DEFAULT_TIMEOUT: Milliseconds = 30_000
export const JAM_SEED_MODAL_TIMEOUT: Milliseconds = Math.max(

View file

@ -13,6 +13,7 @@ export const routes = {
orderbook: '/orderbook',
logs: '/logs',
walletJarsDetails: '/wallet/jars',
txHistory: '/wallet/history',
/* walletList: '/', wallet: '/wallet' */
__dev: '/dev',
__devErrorExample: '/dev/error-example',

View file

@ -1,6 +1,7 @@
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it } from 'vitest'
import { jamSettingsStore } from '@/store/jamSettingsStore'
import { withRuntimeLocale } from '@/test/withRuntimeLocale'
import { useJamDisplayContext } from './JamDisplayContext'
import { JamDisplayContextProvider } from './JamDisplayContextProvider'
@ -24,16 +25,18 @@ describe('JamDisplayContextProvider', () => {
})
it('should format amounts with the current display settings', () => {
render(
<JamDisplayContextProvider>
<DisplayProbe />
</JamDisplayContextProvider>,
)
withRuntimeLocale('en-US', () => {
render(
<JamDisplayContextProvider>
<DisplayProbe />
</JamDisplayContextProvider>,
)
expect(screen.getByTestId('default-amount')).toHaveTextContent('123,456,789')
expect(screen.getByTestId('btc-amount')).toHaveTextContent('1.23456789')
expect(screen.getByTestId('hidden-amount')).toHaveTextContent('*****')
expect(screen.getByTestId('sats-symbol')).toBeInTheDocument()
expect(screen.getByTestId('default-amount')).toHaveTextContent('123,456,789')
expect(screen.getByTestId('btc-amount')).toHaveTextContent('1.23456789')
expect(screen.getByTestId('hidden-amount')).toHaveTextContent('*****')
expect(screen.getByTestId('sats-symbol')).toBeInTheDocument()
})
})
it('should hide default amounts when privacy mode is enabled', () => {

View file

@ -84,6 +84,7 @@ interface JamWalletInfoContextType {
walletName: string | null
walletBalanceSummary: WalletBalanceSummary
maxJarAvailableBalance: AmountSats
hasEligibleFidelityBondUtxo: boolean
fidelityBondSummary: FidelityBondSummary
addressSummary: AddressSummary
accountSummary: AccountSummary

View file

@ -1,3 +1,5 @@
import { sha256 } from '@noble/hashes/sha2.js'
import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js'
import { render, screen, waitFor } from '@testing-library/react'
import { Network } from 'bitcoin-address-validation'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@ -75,7 +77,7 @@ const utxo = (overrides: Partial<Utxo>): Utxo => ({
...overrides,
})
const walletInfo = (): WalletInfoApiObject =>
const walletInfo = (status = 'new'): WalletInfoApiObject =>
({
wallet_name: 'testing.jmdat',
total_balance: '0',
@ -89,7 +91,7 @@ const walletInfo = (): WalletInfoApiObject =>
{
address,
hd_path: "m/84'/1'/0'/0/0",
status: 'new',
status,
label: '',
balance: 0,
used_count: 0,
@ -145,6 +147,7 @@ describe('<JamWalletInfoContextProvider />', () => {
expect(context?.jars.map((jar) => jar.jarIndex)).toEqual([0, 1, 2, 3, 4, 7])
expect(context?.jars.find((jar) => jar.jarIndex === 7)?.name).toBe('Jar #7')
expect(context?.fidelityBondSummary.fbOutputs.map((entry) => entry.utxo)).toEqual([`${txid('c')}:2`])
expect(context?.hasEligibleFidelityBondUtxo).toBe(false)
expect(context?.accountSummary[0].branches[0]).toMatchObject({
type: 'external addresses',
derivation: "m/84'/1'/0'/0",
@ -160,6 +163,19 @@ describe('<JamWalletInfoContextProvider />', () => {
expect(context?.error).toBeNull()
})
it('reports when an eligible fidelity-bond UTXO is available', () => {
let context: ReturnType<typeof useJamWalletInfoContext> | undefined
mocks.walletInfo = walletInfo('cj-out')
render(
<JamWalletInfoContextProvider walletFileName="testing.jmdat">
<CaptureWalletInfo onContext={(value) => (context = value)} />
</JamWalletInfoContextProvider>,
)
expect(context?.hasEligibleFidelityBondUtxo).toBe(true)
})
it('handles malformed accounts, missing branches, and entries without a status', () => {
let context: ReturnType<typeof useJamWalletInfoContext> | undefined
mocks.walletInfo = {
@ -269,3 +285,92 @@ describe('<JamWalletInfoContextProvider />', () => {
expect(balanceSummary?.calculatedTotalBalanceInSats).toBe(25_000)
})
})
/**
* Reference implementation of the old O(n²) combinedUtxosHash logic.
* Used only in tests to verify the optimized implementation is a drop-in replacement.
*/
const combinedUtxosHashReference = (utxoList: Utxo[]): string => {
const utxoIds = utxoList.map((it) => hexToBytes(it.utxo.split(':', 1)[0]))
const combinedUtxoIds = new Uint8Array(utxoIds.reduce((acc, current) => [...acc, ...current], [] as number[]))
return bytesToHex(sha256(combinedUtxoIds))
}
describe('combinedUtxosHash — optimized vs reference (regression)', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.waitQueryError = null
mocks.walletInfo = undefined
mocks.utxosRefetch.mockResolvedValue({ data: { utxos: [] } })
mocks.displayWalletRefetch.mockResolvedValue({ data: { walletinfo: undefined } })
})
/** Render the provider with the given UTXO list and capture the computed utxosHashHex. */
const captureHash = (utxoList: Utxo[]): string => {
mocks.utxos = utxoList
let captured = ''
render(
<JamWalletInfoContextProvider walletFileName="testing.jmdat">
<CaptureWalletInfo onContext={(context) => (captured = context.utxosHashHex)} />
</JamWalletInfoContextProvider>,
)
return captured
}
it('produces the same hash for an empty UTXO list', () => {
const utxoList: Utxo[] = []
const actual = captureHash(utxoList)
expect(actual).toBe(combinedUtxosHashReference(utxoList))
expect(actual).toBe('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855')
})
it('produces the same hash for a single UTXO', () => {
const utxoList = [utxo({ utxo: `${txid('a')}:0` })]
const actual = captureHash(utxoList)
expect(actual).toBe(combinedUtxosHashReference(utxoList))
expect(actual).toBe('e0e77a507412b120f6ede61f62295b1a7b2ff19d3dcc8f7253e51663470c888e')
})
it('is okay for it to produce the same hash for different output in same txid', () => {
const utxoList0 = [utxo({ utxo: `${txid('a')}:0` })]
const actual0 = captureHash(utxoList0)
expect(actual0).toBe(combinedUtxosHashReference(utxoList0))
expect(actual0).toBe('e0e77a507412b120f6ede61f62295b1a7b2ff19d3dcc8f7253e51663470c888e')
const utxoList1 = [utxo({ utxo: `${txid('a')}:1` })]
const actual1 = captureHash(utxoList1)
expect(actual1).toBe(combinedUtxosHashReference(utxoList1))
expect(actual1).toBe('e0e77a507412b120f6ede61f62295b1a7b2ff19d3dcc8f7253e51663470c888e')
expect(actual1).toBe(actual0)
})
it('produces the same hash for multiple UTXOs', () => {
const utxoList = [
utxo({ utxo: `${txid('a')}:0` }),
utxo({ utxo: `${txid('b')}:1` }),
utxo({ utxo: `${txid('c')}:2` }),
]
const actual = captureHash(utxoList)
expect(actual).toBe(combinedUtxosHashReference(utxoList))
expect(actual).toBe('6bdaf8651d78f983ab7ce864414ace095b2942cbe5ff43deade48d51aa34b2cd')
})
it('produces the same hash for UTXOs whose txid bytes vary across the full byte range', () => {
const utxoList = [
utxo({ utxo: `${txid('1')}:0` }),
utxo({ utxo: `${'ab'.repeat(32)}:3` }),
utxo({ utxo: `${'ff'.repeat(32)}:7` }),
]
expect(captureHash(utxoList)).toBe(combinedUtxosHashReference(utxoList))
})
it('produces the same hash for a large UTXO set (50 entries)', () => {
const chars = '0123456789abcdef'
const utxoList = Array.from({ length: 50 }, (_, i) => {
const ch = chars[i % chars.length]
return utxo({ utxo: `${ch.repeat(64)}:${i}`, mixdepth: i % 5 })
})
expect(captureHash(utxoList)).toBe(combinedUtxosHashReference(utxoList))
})
})

View file

@ -1,6 +1,6 @@
import { useMemo, useState, type Dispatch, type PropsWithChildren, type SetStateAction } from 'react'
import { sha256 } from '@noble/hashes/sha2.js'
import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js'
import { bytesToHex, concatBytes, hexToBytes } from '@noble/hashes/utils.js'
import { CancelledError, useMutation, useQuery } from '@tanstack/react-query'
import { getAddressInfo } from 'bitcoin-address-validation'
import { useQueryDisplayWallet, type WalletInfoApiObject } from '@/hooks/useQueryDisplayWallet'
@ -131,7 +131,7 @@ interface JamWalletInfoContextProviderProps {
const combinedUtxosHash = (utxos: Utxo[]) => {
const utxoIds = utxos.map((it) => hexToBytes(it.utxo.split(':', 1)[0]))
const combinedUtxoIds = new Uint8Array(utxoIds.reduce((acc, current) => [...acc, ...current], [] as number[]))
const combinedUtxoIds = concatBytes(...utxoIds)
return sha256(combinedUtxoIds)
}
@ -198,6 +198,9 @@ export const JamWalletInfoContextProvider = ({
: toAccountSummary(displayWalletQuery.walletInfo)
const addressSummary =
displayWalletQuery.walletInfo === undefined ? EMPTY_ADDRESS_SUMMARY : toAddressSummary(accountSummary)
const hasEligibleFidelityBondUtxo = utxos.some((utxo) =>
fb.utxo.isEligibleForCreation(utxo, addressSummary[utxo.address]?.status),
)
const detectedNetwork = useMemo(() => {
const eligibleAddress = Object.values(addressSummary).find((it) => it.info !== undefined)
@ -248,6 +251,7 @@ export const JamWalletInfoContextProvider = ({
walletName: walletFileName ? walletDisplayName(walletFileName) : null,
walletBalanceSummary: walletBalanceSummary,
maxJarAvailableBalance,
hasEligibleFidelityBondUtxo,
fidelityBondSummary,
addressSummary,
accountSummary,

View file

@ -0,0 +1,47 @@
import { renderHook } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { useQueryOrderbook } from './useQueryOrderbook'
const queryMock = vi.fn<(options: unknown) => unknown>()
vi.mock('@/lib/queryClient', () => ({
withQueryDelay: (queryFn: unknown) => queryFn,
}))
vi.mock('@tanstack/react-query', () => ({
useQuery: (options: unknown): unknown => queryMock(options),
}))
vi.mock('@/lib/api/orderbook', () => ({
fetchOrderbook: vi.fn(),
}))
describe('useQueryOrderbook', () => {
it('returns hasOrders: true when orderbook offers exist', () => {
const mockOrderbook = {
offers: [{ counterparty: 'maker1' }],
}
queryMock.mockReturnValue({ data: mockOrderbook, isLoading: false, isError: false })
const { result } = renderHook(() => useQueryOrderbook())
expect(result.current.hasOrders).toBe(true)
expect(result.current.queryResult.data).toEqual(mockOrderbook)
})
it('returns hasOrders: false when orderbook offers array is empty', () => {
queryMock.mockReturnValue({ data: { offers: [] }, isLoading: false, isError: false })
const { result } = renderHook(() => useQueryOrderbook())
expect(result.current.hasOrders).toBe(false)
})
it('returns hasOrders: false when data is undefined', () => {
queryMock.mockReturnValue({ data: undefined, isLoading: false, isError: false })
const { result } = renderHook(() => useQueryOrderbook())
expect(result.current.hasOrders).toBe(false)
})
})

View file

@ -0,0 +1,28 @@
import { useQuery, type UseQueryResult } from '@tanstack/react-query'
import { fetchOrderbook, type OrderbookResponse } from '@/lib/api/orderbook'
import { withQueryDelay } from '@/lib/queryClient'
export type UseQueryOrderbookResult = {
hasOrders: boolean
queryResult: UseQueryResult<OrderbookResponse, unknown>
}
export function useQueryOrderbook(
options: Omit<Parameters<typeof useQuery<OrderbookResponse>>[0], 'queryFn' | 'queryKey'> = {},
): UseQueryOrderbookResult {
const queryResult = useQuery({
queryKey: ['orderbook-precheck'],
queryFn: withQueryDelay(fetchOrderbook, {
// avoid flickering and let user briefly know that something is happening in the background
delayBefore: 1_000,
}),
retry: false,
staleTime: 30 * 1_000,
...options,
})
return {
hasOrders: (queryResult.data?.offers.length ?? 0) > 0,
queryResult,
}
}

View file

@ -0,0 +1,72 @@
import { renderHook } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import type { WalletFileName } from '@/lib/utils'
import { useQueryWalletHistory } from './useQueryWalletHistory'
const queryMock = vi.fn<(options: unknown) => unknown>()
vi.mock('@tanstack/react-query', () => ({
useQuery: (options: unknown) => queryMock(options),
}))
vi.mock('zustand', () => ({
useStore: () => ({ session: 'mock-session' }),
}))
vi.mock('@/hooks/useApiClient', () => ({
useApiClient: () => ({}),
}))
vi.mock('@/constants/debugFeatures', () => ({
isDevMode: () => false,
}))
vi.mock('@/lib/queryClient', () => ({
withQueryDelay: (function_: unknown) => function_,
}))
vi.mock('@/store/jmSessionStore', () => ({
jmSessionStore: {},
}))
vi.mock('@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query', () => ({
wallethistoryOptions: () => ({ queryKey: ['history'], queryFn: vi.fn() }),
}))
describe('useQueryWalletHistory', () => {
it('returns history and queryResult when data exists', () => {
const mockHistory = [{ txid: 'tx1', timestamp: '2026-07-28' }]
queryMock.mockReturnValue({ data: { history: mockHistory } })
const { result } = renderHook(() => useQueryWalletHistory({ walletFileName: 'wallet.jmdat' }))
expect(result.current.history).toEqual(mockHistory)
expect(result.current.queryResult.data?.history).toEqual(mockHistory)
})
it('returns empty array when data does not exist', () => {
queryMock.mockReturnValue({ data: null })
const { result } = renderHook(() => useQueryWalletHistory({ walletFileName: 'wallet.jmdat' }))
expect(result.current.history).toEqual([])
})
it('is not enabled if walletFileName is empty', () => {
queryMock.mockImplementation((options) => options)
const { result } = renderHook(() =>
useQueryWalletHistory({ walletFileName: undefined as unknown as WalletFileName }),
)
expect((result.current.queryResult as { enabled?: boolean }).enabled).toBe(false)
})
it('is not enabled if explicitly disabled', () => {
queryMock.mockImplementation((options) => options)
const { result } = renderHook(() => useQueryWalletHistory({ walletFileName: 'wallet.jmdat', enabled: false }))
expect((result.current.queryResult as { enabled?: boolean }).enabled).toBe(false)
})
})

View file

@ -0,0 +1,67 @@
import { useEffect } from 'react'
import { wallethistoryOptions } from '@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query'
import type { HistoryEntry, WalletHistoryResponse } from '@joinmarket-webui/joinmarket-ng-api-ts/jm'
import { useQuery, type UseQueryResult } from '@tanstack/react-query'
import { useStore } from 'zustand'
import { isDevMode } from '@/constants/debugFeatures'
import { useApiClient } from '@/hooks/useApiClient'
import { withQueryDelay } from '@/lib/queryClient'
import type { WalletFileName } from '@/lib/utils'
import { jmSessionStore } from '@/store/jmSessionStore'
export type { HistoryEntry } from '@joinmarket-webui/joinmarket-ng-api-ts/jm'
export type UseQueryWalletHistoryResult = {
history: HistoryEntry[]
queryResult: UseQueryResult<WalletHistoryResponse, unknown>
}
interface UseQueryWalletHistoryProps {
walletFileName: WalletFileName
limit?: number
enabled?: boolean
utxosHashHex?: string
}
const EMPTY_HISTORY: HistoryEntry[] = []
export function useQueryWalletHistory({
walletFileName,
limit,
enabled = true,
utxosHashHex = '',
}: UseQueryWalletHistoryProps): UseQueryWalletHistoryResult {
const client = useApiClient()
const jmSession = useStore(jmSessionStore, (state) => state.state?.session)
const queryOptions = wallethistoryOptions({
client,
path: { walletname: walletFileName || '' },
query: { limit },
meta: {
cacheBuster: utxosHashHex,
},
})
const queryResult = useQuery({
...queryOptions,
queryFn: withQueryDelay(queryOptions.queryFn, {
// simulate slow mainnet responses in dev mode
throttle: isDevMode() ? 210 : 0,
}),
enabled: enabled && !!walletFileName && !!jmSession,
})
const { refetch } = queryResult
useEffect(() => {
if (utxosHashHex && enabled) {
void refetch()
}
}, [utxosHashHex, enabled, refetch])
return {
history: queryResult.data?.history ?? EMPTY_HISTORY,
queryResult,
}
}

View file

@ -80,6 +80,9 @@
"item_jars": {
"label": "Jars"
},
"item_history": {
"label": "History"
},
"item_tour": {
"label": "Tour"
},
@ -290,6 +293,8 @@
"use_address_chunking_enabled": "Address display chunking enabled",
"use_address_chunking_disabled": "Address display chunking disabled",
"label_select_language": "Language",
"label_select_language_title": "Select language",
"label_select_language_aria_label": "$t(settings.label_select_language_title)",
"show_seed": "Show seed phrase",
"show_xpubs": "Show account xpubs",
"reveal_seed": "Reveal seed phrase",
@ -569,7 +574,14 @@
"text_txfee": "Transaction Fee",
"text_offer_id": "Offer ID",
"text_offer_type_absolute": "absolute",
"text_offer_type_relative": "relative"
"text_offer_type_relative": "relative",
"text_orderbook_checking": "Checking local orderbook...",
"text_orderbook_visible": "Visible in local orderbook",
"text_orderbook_missing": "Not found in local orderbook yet",
"text_orderbook_error": "Could not check local orderbook",
"text_fidelity_bond": "Fidelity Bond",
"text_bond_value": "Bond value",
"text_bond_locktime": "locked until {{date}}"
},
"report": {
"title": "Earnings Report",
@ -585,7 +597,6 @@
"heading_earned": "Earned",
"heading_notes": "Notes",
"text_button_generate_demo_report": "Generate demo entry",
"text_button_download_csv": "Download",
"stats": {
"earned_total": "Earned (total)",
"earned_90days": "Earned (90 days)",
@ -675,6 +686,8 @@
"create_form": {
"confirmation_toggle_title": "I have reviewed my inputs carefully",
"confirmation_toggle_subtitle": "I understand that the locked funds will be inaccessible for the given duration",
"alert_no_eligible_utxos_title": "No eligible UTXOs",
"alert_no_eligible_utxos_description": "A Fidelity Bond must be funded by an unfrozen collaborative transaction output (cj-out). <1>Complete a collaborative transaction</1> or <3>unfreeze an eligible UTXO</3> to continue.",
"button_create": "Create Fidelity Bond"
},
"existing": {
@ -751,6 +764,10 @@
"text_orderbook_summary_filtered_one": "found {{ count }} offer by {{ counterpartyCount }} counterparty",
"text_orderbook_summary_filtered_other": "found {{ count }} offers by {{ counterpartyCount }} counterparties",
"alert_empty_orderbook": "Orderbook is empty",
"alert_precheck_empty_title": "No active makers found",
"alert_precheck_empty_description": "Your local Orderbook appears to be empty right now. Collaborative transactions might fail. Please check <1>your local Orderbook</1>.",
"alert_precheck_empty_text_button_check": "Check again",
"alert_precheck_empty_text_button_checking": "Checking...",
"error_loading_orderbook_failed": "Error while loading the orderbook. Your current local setup might not support fetching the orderbook. Reason: {{ reason }}",
"text_offer_type_absolute": "absolute",
"text_offer_type_relative": "relative",
@ -899,5 +916,30 @@
"subtitle_one": "The selected UTXO is eligible for inclusion in the transaction.",
"subtitle_other": "The selected UTXOs are eligible for inclusion in the transaction.",
"text_subtitle_addon": "Unselected UTXOs will be frozen and can be unfrozen later anytime."
},
"tx_history": {
"title": "Transaction History (Experimental)",
"section_title": "Recent Activity",
"overlay_title": "Transaction History (Experimental)",
"button_view_all": "View All",
"button_load_more": "Load More",
"button_generate_demo_entry": "Add Demo Entry",
"button_reload_title": "Reload",
"expand_history": "Show transaction history",
"collapse_history": "Hide transaction history",
"empty_history": "No transactions found.",
"error_loading": "Failed to load transaction history.",
"copy_txid_success": "Transaction ID copied.",
"copy_txid_error": "Failed to copy transaction ID.",
"column_title_date": "Date",
"column_title_role": "Type",
"column_title_amount": "Amount",
"column_title_net_fee": "Net Fee",
"column_title_confirmations": "Confs.",
"column_title_txid": "Transaction",
"label_role_maker": "Maker",
"label_role_taker": "Taker",
"label_role_send": "Send",
"label_role_deposit": "Deposit"
}
}

View file

@ -5,11 +5,18 @@ export interface OrderbookOffer {
counterparty: string
oid: number
ordertype: OfferType
minsize: AmountSats | null | undefined
maxsize: AmountSats | null | undefined
txfee: number | null | undefined
cjfee: number | null | undefined
fidelity_bond_value: number | null | undefined
minsize?: AmountSats | null
maxsize?: AmountSats | null
txfee?: number | null
cjfee?: number | null
fidelity_bond_value?: number | null
fidelity_bond_verification_stale?: boolean | null
directory_nodes?: string[] | null
directly_reachable?: boolean | null
// TODO: many more fields, e.g.:
// fidelity_bond_value
// fidelity_bond_verified
// features { neutrino_compat: true, nick_auth: true, peerlist_features: true, … }
}
export interface OrderbookFidelityBond {

View file

@ -180,6 +180,19 @@ describe('utils', () => {
expect(fb.utxo.allAreFrozen(utxos)).toBe(false)
})
it('should identify UTXOs eligible for fidelity-bond creation', () => {
expect(fb.utxo.isEligibleForCreation(makeUtxo('foo:0'), 'cj-out')).toBe(true)
expect(fb.utxo.isEligibleForCreation(makeUtxo('foo:0', '', true), 'cj-out')).toBe(false)
expect(fb.utxo.isEligibleForCreation(makeUtxo('foo:0'), 'deposit')).toBe(false)
expect(fb.utxo.isEligibleForCreation(makeUtxo('foo:0'))).toBe(false)
const fidelityBond = {
...makeUtxo('foo:0'),
locktime: '2999-01-01 00:00:00',
} as Utxo
expect(fb.utxo.isEligibleForCreation(fidelityBond, 'cj-out')).toBe(false)
})
describe('isLocked', () => {
const now = Date.UTC(2009, 0, 3)

View file

@ -98,6 +98,10 @@ export const utxo = (() => {
const isFidelityBond = (utxo: Utxo): utxo is FidelityBondUtxo => !!utxo.locktime
// TODO: allow selecting frozen UTXOs and unfreeze them before sending the transaction
const isEligibleForCreation = (utxo: Utxo, addressStatus?: string) =>
!utxo.frozen && !isFidelityBond(utxo) && addressStatus === 'cj-out'
const getLocktime = (utxo: Utxo): Milliseconds | null => {
if (!isFidelityBond(utxo)) return null
@ -115,5 +119,14 @@ export const utxo = (() => {
return locktime !== null && locktime >= refTime
}
return { isEqual, isInList, utxosToFreeze, allAreFrozen, isFidelityBond, isLocked, getLocktime }
return {
isEqual,
isInList,
utxosToFreeze,
allAreFrozen,
isFidelityBond,
isEligibleForCreation,
isLocked,
getLocktime,
}
})()

View file

@ -1,4 +1,4 @@
import { Network } from 'bitcoin-address-validation'
import { getAddressInfo, Network } from 'bitcoin-address-validation'
import { describe, expect, it } from 'vitest'
import type { AddressSummary } from '@/context/JamWalletInfoContext'
import {
@ -7,23 +7,41 @@ import {
isReusedAddress,
isValidAddress,
sourceJarField,
blockHeightField,
INPUT_BLOCK_HEIGHT_MIN,
} from './formValidation'
const mainnetAddress = '1BitcoinEaterAddressDontSend8MUo1T'
const testnetAddress = 'mipcBbFg9gMiCh81Kj8tqqdgoZub1ZJRfn'
const regtestBech32Address = 'bcrt1q6rz28mcfaxtmd6v789l9rrlrusdprr9pz3cppk'
const regtestLegacyAddressLabeledTestnet = 'mkpZhYtJu2r87Js3pDiWJDmPte2NRZ8bJV'
const testnetBech32Address = 'tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx'
// bech32m/taproot pair - same witness program, differing only by HRP (tb1p vs bcrt1p)
const testnetTaprootAddress = 'tb1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vq47zagq'
const regtestTaprootAddress = 'bcrt1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqc8gma6'
const regtestLegacyAddressLabelledTestnet = 'mkpZhYtJu2r87Js3pDiWJDmPte2NRZ8bJV'
const addressSummary = {
[mainnetAddress]: { address: mainnetAddress, used: false },
} as unknown as AddressSummary
describe('isValidAddress', () => {
it('accepts a valid address and rejects everything else', () => {
expect(isValidAddress(mainnetAddress)).toBe(true)
it.each([
mainnetAddress,
testnetAddress,
regtestBech32Address,
testnetBech32Address,
testnetTaprootAddress,
regtestTaprootAddress,
regtestLegacyAddressLabelledTestnet,
])('accepts valid addresses', (address) => {
expect(isValidAddress(address)).toBe(true)
})
it('reject invalid addresses', () => {
expect(isValidAddress('not-an-address')).toBe(false)
expect(isValidAddress('')).toBe(false)
expect(isValidAddress(undefined)).toBe(false)
expect(isValidAddress(null)).toBe(false)
expect(isValidAddress(42)).toBe(false)
})
})
@ -32,6 +50,7 @@ describe('isAddressOnNetwork', () => {
it('matches the address network', () => {
expect(isAddressOnNetwork(mainnetAddress, Network.mainnet)).toBe(true)
expect(isAddressOnNetwork(mainnetAddress, Network.testnet)).toBe(false)
expect(isAddressOnNetwork(testnetAddress, Network.mainnet)).toBe(false)
expect(isAddressOnNetwork(testnetAddress, Network.testnet)).toBe(true)
})
@ -39,17 +58,33 @@ describe('isAddressOnNetwork', () => {
expect(isAddressOnNetwork('not-an-address', Network.mainnet)).toBe(false)
})
it('treats testnet and regtest as interchangeable for base58 addresses ambiguous between the two', () => {
// bech32 regtest addresses are unambiguous and match regtest directly...
it('distinguishes testnet and regtest for bech32 and bech32m addresses via their distinct HRP', () => {
// bech32/bech32m addresses carry a network-specific prefix (tb1 vs bcrt1), so each is only
// valid on its own network - a regtest address must not pass on a testnet wallet, nor vice versa.
// bech32 (segwit v0):
expect(isAddressOnNetwork(regtestBech32Address, Network.regtest)).toBe(true)
expect(isAddressOnNetwork(regtestBech32Address, Network.testnet)).toBe(true) // TODO: can be detected and differentiated for `p2wpkh`
// ...but a legacy address on a regtest wallet is labeled "testnet" by the library, so it
// must still be accepted when the wallet's detected network is regtest.
expect(isAddressOnNetwork(regtestLegacyAddressLabeledTestnet, Network.regtest)).toBe(true)
expect(isAddressOnNetwork(regtestLegacyAddressLabeledTestnet, Network.testnet)).toBe(true)
// mainnet is never ambiguous with testnet/regtest.
expect(isAddressOnNetwork(mainnetAddress, Network.regtest)).toBe(false)
expect(isAddressOnNetwork(regtestBech32Address, Network.testnet)).toBe(false)
expect(isAddressOnNetwork(testnetBech32Address, Network.testnet)).toBe(true)
expect(isAddressOnNetwork(testnetBech32Address, Network.regtest)).toBe(false)
// bech32m (taproot):
expect(isAddressOnNetwork(regtestTaprootAddress, Network.regtest)).toBe(true)
expect(isAddressOnNetwork(regtestTaprootAddress, Network.testnet)).toBe(false)
expect(isAddressOnNetwork(testnetTaprootAddress, Network.testnet)).toBe(true)
expect(isAddressOnNetwork(testnetTaprootAddress, Network.regtest)).toBe(false)
// ...and none pass on mainnet.
expect(isAddressOnNetwork(regtestBech32Address, Network.mainnet)).toBe(false)
expect(isAddressOnNetwork(testnetBech32Address, Network.mainnet)).toBe(false)
expect(isAddressOnNetwork(regtestTaprootAddress, Network.mainnet)).toBe(false)
expect(isAddressOnNetwork(testnetTaprootAddress, Network.mainnet)).toBe(false)
})
it('treats testnet and regtest as interchangeable for ambiguous base58 addresses', () => {
expect(getAddressInfo(regtestLegacyAddressLabelledTestnet).network, 'sanity check').toBe(Network.testnet)
// A base58 (P2PKH here; P2SH shares the trait) address on a regtest wallet is labeled "testnet"
// by the library because the two share version bytes, so it must still be accepted on regtest.
expect(isAddressOnNetwork(regtestLegacyAddressLabelledTestnet, Network.regtest)).toBe(true)
expect(isAddressOnNetwork(regtestLegacyAddressLabelledTestnet, Network.testnet)).toBe(true)
})
})
@ -131,3 +166,53 @@ describe('destinationAddressField', () => {
expect(() => reusedSchema.validateSync(mainnetAddress)).toThrow('reused')
})
})
describe('blockHeightField', () => {
// eslint-disable-next-line unicorn/consistent-function-scoping -- okay in tests
const invalidMessage = ({ min, max }: { min: number; max: number }) => `Invalid blockheight ${min}-${max}`
it('handles currentBlockHeight = 0', () => {
const schema = blockHeightField({ currentBlockHeight: 0, messages: { invalid: invalidMessage } })
// min should be 0, max should be 0
expect(schema.isValidSync(-1)).toBe(false)
expect(schema.isValidSync(0)).toBe(true)
expect(schema.isValidSync(1)).toBe(false)
})
it('handles currentBlockHeight = undefined', () => {
const schema = blockHeightField({ currentBlockHeight: undefined, messages: { invalid: invalidMessage } })
// min should be 0, max should be Number.MAX_SAFE_INTEGER
expect(schema.isValidSync(-1)).toBe(false)
expect(schema.isValidSync(0)).toBe(true)
expect(schema.isValidSync(100)).toBe(true)
expect(schema.isValidSync(Number.MAX_SAFE_INTEGER + 1)).toBe(false)
})
it('handles currentBlockHeight = null', () => {
// @ts-expect-error Intentionally pass null to test JS boundary cases
const schema = blockHeightField({ currentBlockHeight: null, messages: { invalid: invalidMessage } })
expect(schema.isValidSync(-1)).toBe(false)
expect(schema.isValidSync(0)).toBe(true)
expect(schema.isValidSync(100)).toBe(true)
})
it('handles currentBlockHeight > minBlockHeight', () => {
const schema = blockHeightField({ currentBlockHeight: 100, messages: { invalid: invalidMessage } })
// min should be 0, max should be 100
expect(schema.isValidSync(-1)).toBe(false)
expect(schema.isValidSync(0)).toBe(true)
expect(schema.isValidSync(50)).toBe(true)
expect(schema.isValidSync(100)).toBe(true)
expect(schema.isValidSync(101)).toBe(false)
})
it('handles currentBlockHeight == minBlockHeight (0)', () => {
const schema = blockHeightField({
currentBlockHeight: INPUT_BLOCK_HEIGHT_MIN,
messages: { invalid: invalidMessage },
})
expect(schema.isValidSync(INPUT_BLOCK_HEIGHT_MIN - 1)).toBe(false)
expect(schema.isValidSync(INPUT_BLOCK_HEIGHT_MIN)).toBe(true)
expect(schema.isValidSync(INPUT_BLOCK_HEIGHT_MIN + 1)).toBe(false)
})
})

View file

@ -1,4 +1,10 @@
import { getAddressInfo, Network, validate as isValidBitcoinAddress } from 'bitcoin-address-validation'
import {
getAddressInfo,
Network,
validate as isValidBitcoinAddress,
AddressType,
type AddressInfo,
} from 'bitcoin-address-validation'
import * as yup from 'yup'
import type { AddressSummary } from '@/context/JamWalletInfoContext'
import type { BitcoinAddress, BlockHeight, JarIndex } from '@/types/global'
@ -11,17 +17,32 @@ import { isValidInteger } from './utils'
export const isValidAddress = (value: unknown): value is BitcoinAddress =>
typeof value === 'string' && isValidBitcoinAddress(value)
// Legacy (base58) addresses share the same version bytes on testnet and regtest, so
// bitcoin-address-validation can't tell them apart and always labels them "testnet" -
// only bech32 addresses carry a distinct "bcrt1" prefix. Treat the two as interchangeable
// so a regtest wallet doesn't reject its own legacy-style addresses as "wrong network".
const AMBIGUOUS_TESTNET_REGTEST_NETWORKS: ReadonlySet<Network> = new Set([Network.testnet, Network.regtest])
// p2pkh/p2sh addresses (base58) share the same version bytes on testnet and regtest, so
// address validation can't tell them apart and always labels them "testnet".
// Treat the two as interchangeable for those so a regtest wallet doesn't reject its own
// legacy-style addresses as "wrong network".
// Types like p2wpkh/p2tr (bech32) carry a distinct HRP per network (tb1 vs bcrt1), so they are
// already identified correctly - don't relax them, or a bcrt1… (regtest) address would
// wrongly pass on a testnet wallet and vice versa. Only base58 addresses are ambiguous.
const AMBIGUOUS_NETWORKS_BY_ADDRESS_TYPE: ReadonlyMap<AddressType, Network[]> = new Map([
[AddressType.p2pkh, [Network.testnet, Network.regtest]],
[AddressType.p2sh, [Network.testnet, Network.regtest]],
])
export const isAddressOnNetwork = (value: string, network: Network): boolean => {
const isAmbiguousAddressForNetwork = (info: AddressInfo, expectedNetwork: Network) => {
// Bech32/bech32m addresses carry a distinct HRP per network (tb1 vs bcrt1), so they are
// already identified correctly - don't relax them, or a bcrt1… (regtest) address would
// wrongly pass on a testnet wallet and vice versa. Only base58 addresses are ambiguous.
return [info.network, expectedNetwork].every(
(it) => AMBIGUOUS_NETWORKS_BY_ADDRESS_TYPE.get(info.type)?.includes(it) ?? false,
)
}
export const isAddressOnNetwork = (value: string, expectedNetwork: Network): boolean => {
try {
const addressNetwork = getAddressInfo(value).network
if (addressNetwork === network) return true
return AMBIGUOUS_TESTNET_REGTEST_NETWORKS.has(addressNetwork) && AMBIGUOUS_TESTNET_REGTEST_NETWORKS.has(network)
const addressInfo = getAddressInfo(value)
if (addressInfo.network === expectedNetwork) return true
return isAmbiguousAddressForNetwork(addressInfo, expectedNetwork)
} catch (_ignoredOnPurpose) {
return false
}
@ -100,7 +121,7 @@ export const blockHeightField = ({
}
}) => {
const minBlockHeight = Math.min(INPUT_BLOCK_HEIGHT_MIN, currentBlockHeight || INPUT_BLOCK_HEIGHT_MIN)
const maxBlockheight = Math.max(minBlockHeight, currentBlockHeight || INPUT_BLOCK_HEIGHT_MAX)
const maxBlockheight = Math.max(minBlockHeight, currentBlockHeight ?? INPUT_BLOCK_HEIGHT_MAX)
const invalidBlockheightMessage = invalid({ min: minBlockHeight, max: maxBlockheight })
return yup

View file

@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { JM_WALLET_FILE_EXTENSION } from '@/constants/jm'
import { withRuntimeLocale } from '@/test/withRuntimeLocale'
import {
cn,
debounce,
@ -37,24 +38,6 @@ import {
} from './utils'
import type { WalletFileName } from './utils'
const withRuntimeLocale = (locale: string, callback: () => void) => {
/* eslint-disable unicorn/no-this-outside-of-class -- mocking Number.prototype.toLocaleString requires `this` */
const numberToLocaleStringMock = vi.spyOn(Number.prototype, 'toLocaleString').mockImplementation(function (
this: number,
locales,
options,
) {
return Intl.NumberFormat(locales ?? locale, options).format(this)
})
/* eslint-enable unicorn/no-this-outside-of-class */
try {
callback()
} finally {
numberToLocaleStringMock.mockRestore()
}
}
describe('cn', () => {
it('should merge class names correctly', () => {
expect(cn('text-red-500', 'bg-blue-500')).toBe('text-red-500 bg-blue-500')
@ -502,8 +485,10 @@ describe('formatBtc', () => {
})
it('should handle large BTC values', () => {
expect(formatBtc(21000000)).toBe('21,000,000.00000000')
expect(formatBtc(100.99999999)).toBe('100.99999999')
withRuntimeLocale('en-US', () => {
expect(formatBtc(21000000)).toBe('21,000,000.00000000')
expect(formatBtc(100.99999999)).toBe('100.99999999')
})
})
it('should handle very small BTC values', () => {
@ -555,9 +540,11 @@ describe('getBtcParts', () => {
describe('formatSats', () => {
it('should format satoshi values with locale-specific thousands separators', () => {
expect(formatSats(1000)).toBe('1,000')
expect(formatSats(1000000)).toBe('1,000,000')
expect(formatSats(100000000)).toBe('100,000,000') // 1 BTC in sats
withRuntimeLocale('en-US', () => {
expect(formatSats(1000)).toBe('1,000')
expect(formatSats(1000000)).toBe('1,000,000')
expect(formatSats(100000000)).toBe('100,000,000') // 1 BTC in sats
})
})
it('should handle small satoshi values', () => {
@ -568,13 +555,17 @@ describe('formatSats', () => {
})
it('should handle large satoshi values', () => {
expect(formatSats(2100000000000000)).toBe('2,100,000,000,000,000') // 21M BTC in sats
expect(formatSats(12345678901)).toBe('12,345,678,901')
withRuntimeLocale('en-US', () => {
expect(formatSats(2100000000000000)).toBe('2,100,000,000,000,000') // 21M BTC in sats
expect(formatSats(12345678901)).toBe('12,345,678,901')
})
})
it('should handle negative satoshi values', () => {
expect(formatSats(-1000)).toBe('-1,000')
expect(formatSats(-1234567)).toBe('-1,234,567')
withRuntimeLocale('en-US', () => {
expect(formatSats(-1000)).toBe('-1,000')
expect(formatSats(-1234567)).toBe('-1,234,567')
})
})
it('should follow runtime locale when no explicit locale is provided', () => {

View file

@ -2,8 +2,28 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from '@/App.tsx'
import '@/i18n/config'
import { JAM_REPO_URL } from './constants/jam'
import './index.css'
console.debug(
`%c/${'*'.repeat(42)}
* Thanks for testing! Please report anything that looks off.
* Found a bug? Open an issue at ${JAM_REPO_URL}/issues/new?labels=bug&template=bug_report.md
*
* This message should be dropped from preview/prod builds.
* Use the above link and open a bug report if you see this message outside development builds.
${'*'.repeat(42)}/`,
'color:#e2b86a;font-size:14px;',
)
setTimeout(() => {
console.log('%cWarning!', 'color:oklch(0.577 0.245 27.325);font-size:48px;font-weight:bold;')
console.log(
"%cYou opened the browser console, a developer tool. Don't enter or paste code you do not understand. Never share your seed phrase or any other info with anyone. If someone told you to do this, it is very likely a scam.",
'color:oklch(0.777 0.245 27.325);font-size:16px;',
)
}, 2_100)
createRoot(document.querySelector('#root')!).render(
<StrictMode>
<App />

View file

@ -3,8 +3,15 @@ import { persist, createJSONStorage } from 'zustand/middleware'
import { isDevMode } from '@/constants/debugFeatures'
import type { Currency } from '@/types/global'
type PreviewFeatures = {
'tx-history'?: boolean
// add more entries on demand
// 'myCoolNewFeature'?: boolean
}
export type JamSettings = {
developerMode: boolean
previewFeatures: PreviewFeatures | undefined
privateMode: boolean
addressChunking: boolean
currencyUnit: Currency
@ -19,6 +26,7 @@ interface JamSettingsStoreState {
const initial: JamSettings = {
developerMode: isDevMode(),
previewFeatures: isDevMode() ? {} : undefined,
privateMode: false,
addressChunking: true,
currencyUnit: 'sats',
@ -43,3 +51,7 @@ export const useDeveloperMode = () => {
const isDeveloperMode = useStore(jamSettingsStore, (state) => state.state.developerMode)
return { enabled: isDeveloperMode }
}
export const usePreviewFeatures = () => {
return useStore(jamSettingsStore, (state) => state.state.previewFeatures)
}

View file

@ -0,0 +1,74 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { ActiveCollaborativeSendAlert } from '@/components/send/ActiveCollaborativeSendAlert'
import type { PaymentAttempt } from '@/context/JamSessionInfoContext'
import type { Jar } from '@/context/JamWalletInfoContext'
import { TX_FEE_UNITS } from '@/lib/feeConfig'
const meta: Meta<typeof ActiveCollaborativeSendAlert> = {
title: 'Jam/ActiveCollaborativeSendAlert',
component: ActiveCollaborativeSendAlert,
tags: ['autodocs'],
args: {
jars: [],
isAborting: false,
onAbort: () => alert('onAbort clicked'),
},
}
export default meta
type Story = StoryObj<typeof ActiveCollaborativeSendAlert>
export const Default: Story = {
args: {},
}
const jars: Jar[] = [
{
balanceSummary: {
calculatedAvailableBalanceInSats: 100_000,
calculatedConfirmedAvailableBalanceInSats: 100_000,
calculatedAvailableFrozenBalanceInSats: 0,
calculatedFrozenOrLockedBalanceInSats: 0,
calculatedTotalBalanceInSats: 100_000,
},
color: '#e2b86a',
jarIndex: 0,
name: 'Jar Zero',
utxos: [],
},
]
const paymentAttempt: PaymentAttempt = {
createdAt: 1_000,
utxosHashHex: 'abc123hash',
walletFileName: 'Satoshi.jmdat',
data: {
amount: { amount: 21_000, isSweep: false as const, sweepAmount: undefined },
destination: { address: 'bcrt1qdestinationaddress123456789', fromJar: undefined },
isCoinJoin: true,
numCollaborators: 5,
source: { fromJar: 0 as const },
txFee: { txFeeInBlocks: 3, txFeeInSatsPerVbyte: undefined, txFeeUnit: TX_FEE_UNITS.BLOCKS },
},
}
export const WithPaymentAttempt: Story = {
args: {
paymentAttempt,
jars,
},
}
export const WithSweepPaymentAttempt: Story = {
args: {
paymentAttempt: {
...paymentAttempt,
data: {
...paymentAttempt.data,
amount: { amount: undefined, isSweep: true, sweepAmount: 21_000 },
destination: { address: 'bcrt1qdestinationaddress123456789', fromJar: paymentAttempt.data.source.fromJar },
},
},
jars,
},
}

View file

@ -0,0 +1,151 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { OfferCard } from '@/components/earn/OfferCard'
const meta: Meta<typeof OfferCard> = {
title: 'Jam/OfferCard',
component: OfferCard,
tags: ['autodocs'],
}
export default meta
type Story = StoryObj<typeof OfferCard>
export const Absolute: Story = {
args: {
value: {
oid: 0,
ordertype: 'sw0absoffer',
minsize: 90_463,
maxsize: 9_008_744_958,
txfee: 0,
cjfee: '21',
},
nickname: 'TEST7i74jfC9M8MV',
},
}
export const Relative: Story = {
args: {
value: {
oid: 0,
ordertype: 'sw0reloffer',
minsize: 90_463,
maxsize: 9_008_744_958,
txfee: 0,
cjfee: '0.000021',
},
nickname: 'TESTCKqXnDjxnFHN',
},
}
export const Checking: Story = {
args: {
value: {
oid: 0,
ordertype: 'sw0reloffer',
minsize: 90_463,
maxsize: 9_008_744_958,
txfee: 0,
cjfee: '0.000021',
},
nickname: 'TESTCKqXnDjxnFHN',
orderbookStatus: 'checking',
},
}
export const Visible: Story = {
args: {
value: {
oid: 0,
ordertype: 'sw0reloffer',
minsize: 90_463,
maxsize: 9_008_744_958,
txfee: 0,
cjfee: '0.000021',
},
nickname: 'TESTCKqXnDjxnFHN',
orderbookStatus: 'visible',
},
}
export const Missing: Story = {
args: {
value: {
oid: 0,
ordertype: 'sw0reloffer',
minsize: 90_463,
maxsize: 9_008_744_958,
txfee: 0,
cjfee: '0.000021',
},
nickname: 'TESTCKqXnDjxnFHN',
orderbookStatus: 'missing',
},
}
export const Error: Story = {
args: {
value: {
oid: 0,
ordertype: 'sw0reloffer',
minsize: 90_463,
maxsize: 9_008_744_958,
txfee: 0,
cjfee: '0.000021',
},
nickname: 'TESTCKqXnDjxnFHN',
orderbookStatus: 'error',
},
}
export const RelativeWithFidelityBondNotYetInLocalOrderbook: Story = {
args: {
value: {
oid: 0,
ordertype: 'sw0reloffer',
minsize: 90_463,
maxsize: 9_008_744_958,
txfee: 0,
cjfee: '0.000021',
},
nickname: 'TESTCKqXnDjxnFHN',
orderbookStatus: 'visible',
orderbookOffer: {
counterparty: 'TESTCKqXnDjxnFHN',
oid: 0,
ordertype: 'sw0reloffer',
fidelity_bond_value: 0,
},
fidelityBond: {
counterparty: 'TESTCKqXnDjxnFHN',
amount: 123_456_789,
locktime: 1_000_000,
},
},
}
export const RelativeWithFidelityBondInLocalOrderbook: Story = {
args: {
value: {
oid: 0,
ordertype: 'sw0reloffer',
minsize: 90_463,
maxsize: 9_008_744_958,
txfee: 0,
cjfee: '0.000021',
},
nickname: 'TESTCKqXnDjxnFHN',
orderbookStatus: 'visible',
orderbookOffer: {
counterparty: 'TESTCKqXnDjxnFHN',
oid: 0,
ordertype: 'sw0reloffer',
fidelity_bond_value: 123_456_789.1337,
},
fidelityBond: {
counterparty: 'TESTCKqXnDjxnFHN',
amount: 123_456_789,
locktime: 0,
},
},
}

View file

@ -0,0 +1,25 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { OrderbookEmptyAlert } from '@/components/ui/jam/OrderbookEmptyAlert'
const meta: Meta<typeof OrderbookEmptyAlert> = {
title: 'Jam/OrderbookEmptyAlert',
component: OrderbookEmptyAlert,
tags: ['autodocs'],
}
export default meta
type Story = StoryObj<typeof OrderbookEmptyAlert>
export const Default: Story = {
args: {
isChecking: false,
onCheckClick: async () => alert('onCheck clicked'),
},
}
export const Checking: Story = {
args: {
isChecking: true,
onCheckClick: async () => alert('onCheck clicked'),
},
}

View file

@ -0,0 +1,179 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { SWEEP_PRECONDITION_DEFAULT_OPTIONS } from '@/components/sweep/preconditions'
import { PreconditionAlert } from '@/components/ui/jam/PreconditionAlert'
import type { Utxo } from '@/hooks/useQueryUtxos'
const DEFAULT_SUMMARY = Object.freeze({
isFulfilled: false,
options: SWEEP_PRECONDITION_DEFAULT_OPTIONS,
numberOfMissingUtxos: 0,
numberOfMissingConfirmations: 0,
numberOfNonFrozenFidelityBondOutputs: 0,
retryLockedUtxos: [],
})
const makeUtxo = (overrides: Partial<Utxo> = {}): Utxo =>
({
address: 'bc1qsource',
confirmations: 6,
frozen: false,
label: '',
locktime: undefined,
mixdepth: 0,
path: '',
tries_remaining: 3,
utxo: '4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b:0',
value: 100_000,
...overrides,
}) as Utxo
const meta: Meta<typeof PreconditionAlert> = {
title: 'Jam/PreconditionAlert',
component: PreconditionAlert,
tags: ['autodocs'],
args: {
summary: DEFAULT_SUMMARY,
i18nPrefix: 'send.coinjoin_precondition',
},
}
export default meta
type Story = StoryObj<typeof PreconditionAlert>
// --- Send Flow Stories ---
export const SendDefault: Story = {
args: {
i18nPrefix: 'send.coinjoin_precondition',
},
}
export const SendMissingUtxos: Story = {
args: {
i18nPrefix: 'send.coinjoin_precondition',
summary: {
...DEFAULT_SUMMARY,
numberOfMissingUtxos: 3,
},
},
}
export const SendMissingConfirmations: Story = {
args: {
i18nPrefix: 'send.coinjoin_precondition',
summary: {
...DEFAULT_SUMMARY,
numberOfMissingConfirmations: 3,
},
},
}
export const SendNonFrozenFidelityBondsPresent: Story = {
args: {
i18nPrefix: 'send.coinjoin_precondition',
summary: {
...DEFAULT_SUMMARY,
numberOfNonFrozenFidelityBondOutputs: 3,
},
},
}
export const SendRetryLockedUtxos: Story = {
args: {
i18nPrefix: 'send.coinjoin_precondition',
summary: {
...DEFAULT_SUMMARY,
retryLockedUtxos: [
makeUtxo({ mixdepth: 0, value: 21 }),
makeUtxo({ mixdepth: 1, value: 21_000 }),
makeUtxo({ mixdepth: 2, value: 21_000_000 }),
],
},
},
}
export const SendMultiple: Story = {
args: {
i18nPrefix: 'send.coinjoin_precondition',
summary: {
...DEFAULT_SUMMARY,
numberOfMissingUtxos: 3,
numberOfMissingConfirmations: 3,
numberOfNonFrozenFidelityBondOutputs: 3,
retryLockedUtxos: [
makeUtxo({ mixdepth: 0, value: 21 }),
makeUtxo({ mixdepth: 1, value: 21_000 }),
makeUtxo({ mixdepth: 2, value: 21_000_000 }),
],
},
},
}
// --- Sweep Flow Stories ---
export const SweepDefault: Story = {
args: {
i18nPrefix: 'scheduler.precondition',
},
}
export const SweepMissingUtxos: Story = {
args: {
i18nPrefix: 'scheduler.precondition',
summary: {
...DEFAULT_SUMMARY,
numberOfMissingUtxos: 3,
},
},
}
export const SweepMissingConfirmations: Story = {
args: {
i18nPrefix: 'scheduler.precondition',
summary: {
...DEFAULT_SUMMARY,
numberOfMissingConfirmations: 3,
},
},
}
export const SweepNonFrozenFidelityBondsPresent: Story = {
args: {
i18nPrefix: 'scheduler.precondition',
summary: {
...DEFAULT_SUMMARY,
numberOfNonFrozenFidelityBondOutputs: 3,
},
},
}
export const SweepRetryLockedUtxos: Story = {
args: {
i18nPrefix: 'scheduler.precondition',
summary: {
...DEFAULT_SUMMARY,
retryLockedUtxos: [
makeUtxo({ mixdepth: 0, value: 21 }),
makeUtxo({ mixdepth: 1, value: 21_000 }),
makeUtxo({ mixdepth: 2, value: 21_000_000 }),
],
},
},
}
export const SweepMultiple: Story = {
args: {
i18nPrefix: 'scheduler.precondition',
summary: {
...DEFAULT_SUMMARY,
numberOfMissingUtxos: 3,
numberOfMissingConfirmations: 3,
numberOfNonFrozenFidelityBondOutputs: 3,
retryLockedUtxos: [
makeUtxo({ mixdepth: 0, value: 21 }),
makeUtxo({ mixdepth: 1, value: 21_000 }),
makeUtxo({ mixdepth: 2, value: 21_000_000 }),
],
},
},
}

View file

@ -2,6 +2,7 @@ import type { TumblerPhaseResponse } from '@joinmarket-webui/joinmarket-ng-api-t
import type { Meta, StoryObj } from '@storybook/react-vite'
import { ScheduleEntryItem } from '@/components/sweep/ScheduleEntryItem'
import { toScheduleEntry } from '@/components/sweep/scheduleUtils'
import type { Jar } from '@/context/JamWalletInfoContext'
const meta: Meta<typeof ScheduleEntryItem> = {
title: 'Jam/ScheduleEntryItem',
@ -12,6 +13,22 @@ export default meta
type Story = StoryObj<typeof ScheduleEntryItem>
const jars: Jar[] = [
{
balanceSummary: {
calculatedAvailableBalanceInSats: 100_000,
calculatedConfirmedAvailableBalanceInSats: 100_000,
calculatedAvailableFrozenBalanceInSats: 0,
calculatedFrozenOrLockedBalanceInSats: 0,
calculatedTotalBalanceInSats: 100_000,
},
color: '#e2b86a',
jarIndex: 0,
name: 'Jar Zero',
utxos: [],
},
]
const takerPhase: TumblerPhaseResponse = {
kind: 'taker_coinjoin',
index: 0,
@ -20,7 +37,7 @@ const takerPhase: TumblerPhaseResponse = {
started_at: '2026-07-19T10:35:52.775747+00:00',
finished_at: null,
error: null,
mixdepth: 0,
mixdepth: jars[0].jarIndex,
amount: 0,
amount_fraction: null,
counterparty_count: 21,
@ -54,9 +71,28 @@ const makerPhase: TumblerPhaseResponse = {
attempt_count: 0,
}
export const Maker: Story = {
args: {
value: toScheduleEntry(makerPhase, jars),
},
}
export const Active: Story = {
args: {
value: toScheduleEntry(takerPhase, []),
value: toScheduleEntry(takerPhase, jars),
active: true,
},
}
export const ActiveExternal: Story = {
args: {
value: toScheduleEntry(
{
...takerPhase,
destination: 'bcrt1qdestinationaddress123456789',
},
jars,
),
active: true,
},
}
@ -68,7 +104,7 @@ export const Running: Story = {
...takerPhase,
status: 'running',
},
[],
jars,
),
active: false,
},
@ -81,7 +117,7 @@ export const Pending: Story = {
...takerPhase,
status: 'pending',
},
[],
jars,
),
active: false,
},
@ -93,8 +129,9 @@ export const Completed: Story = {
{
...takerPhase,
status: 'completed',
finished_at: '2026-07-19T10:35:52.775747+00:00',
},
[],
jars,
),
active: false,
},
@ -107,7 +144,7 @@ export const Skipped: Story = {
...takerPhase,
status: 'skipped',
},
[],
jars,
),
active: false,
},
@ -120,7 +157,7 @@ export const Cancelled: Story = {
...takerPhase,
status: 'cancelled',
},
[],
jars,
),
active: false,
},
@ -134,14 +171,8 @@ export const Failed: Story = {
status: 'failed',
error: 'Error description',
},
[],
jars,
),
active: false,
},
}
export const Maker: Story = {
args: {
value: toScheduleEntry(makerPhase, []),
},
}

View file

@ -1,116 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { SendCoinjoinPreconditionAlert } from '@/components/send/SendCoinjoinPreconditionAlert'
import { SWEEP_PRECONDITION_DEFAULT_OPTIONS } from '@/components/sweep/preconditions'
import type { Utxo } from '@/hooks/useQueryUtxos'
const DEFAULT_SUMMARY = Object.freeze({
isFulfilled: false,
options: SWEEP_PRECONDITION_DEFAULT_OPTIONS,
numberOfMissingUtxos: 0,
numberOfMissingConfirmations: 0,
numberOfNonFrozenFidelityBondOutputs: 0,
retryLockedUtxos: [],
})
const makeUtxo = (overrides: Partial<Utxo> = {}): Utxo =>
({
address: 'bc1qsource',
confirmations: 6,
frozen: false,
label: '',
locktime: undefined,
mixdepth: 0,
path: '',
tries_remaining: 3,
utxo: '4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b:0',
value: 100_000,
...overrides,
}) as Utxo
const meta: Meta<typeof SendCoinjoinPreconditionAlert> = {
title: 'Jam/SendCoinjoinPreconditionAlert',
component: SendCoinjoinPreconditionAlert,
tags: ['autodocs'],
args: {
summary: DEFAULT_SUMMARY,
},
}
export default meta
type Story = StoryObj<typeof SendCoinjoinPreconditionAlert>
export const Default: Story = {}
export const MissingUtxos: Story = {
args: {
summary: {
...DEFAULT_SUMMARY,
numberOfMissingUtxos: 3,
},
},
}
export const MissingConfirmations: Story = {
args: {
summary: {
...DEFAULT_SUMMARY,
numberOfMissingConfirmations: 3,
},
},
}
export const NonFrozenFidelityBondsPresent: Story = {
args: {
summary: {
...DEFAULT_SUMMARY,
numberOfNonFrozenFidelityBondOutputs: 3,
},
},
}
export const RetryLockedUtxos: Story = {
args: {
summary: {
...DEFAULT_SUMMARY,
retryLockedUtxos: [
makeUtxo({
mixdepth: 0,
value: 21,
}),
makeUtxo({
mixdepth: 1,
value: 21_000,
}),
makeUtxo({
mixdepth: 2,
value: 21_000_000,
}),
],
},
},
}
export const Multiple: Story = {
args: {
summary: {
...DEFAULT_SUMMARY,
numberOfMissingUtxos: 3,
numberOfMissingConfirmations: 3,
numberOfNonFrozenFidelityBondOutputs: 3,
retryLockedUtxos: [
makeUtxo({
mixdepth: 0,
value: 21,
}),
makeUtxo({
mixdepth: 1,
value: 21_000,
}),
makeUtxo({
mixdepth: 2,
value: 21_000_000,
}),
],
},
},
}

View file

@ -1,116 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { SweepPreconditionAlert } from '@/components/sweep/SweepPreconditionAlert'
import { SWEEP_PRECONDITION_DEFAULT_OPTIONS } from '@/components/sweep/preconditions'
import type { Utxo } from '@/hooks/useQueryUtxos'
const DEFAULT_SUMMARY = Object.freeze({
isFulfilled: false,
options: SWEEP_PRECONDITION_DEFAULT_OPTIONS,
numberOfMissingUtxos: 0,
numberOfMissingConfirmations: 0,
numberOfNonFrozenFidelityBondOutputs: 0,
retryLockedUtxos: [],
})
const makeUtxo = (overrides: Partial<Utxo> = {}): Utxo =>
({
address: 'bc1qsource',
confirmations: 6,
frozen: false,
label: '',
locktime: undefined,
mixdepth: 0,
path: '',
tries_remaining: 3,
utxo: '4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b:0',
value: 100_000,
...overrides,
}) as Utxo
const meta: Meta<typeof SweepPreconditionAlert> = {
title: 'Jam/SweepPreconditionAlert',
component: SweepPreconditionAlert,
tags: ['autodocs'],
args: {
summary: DEFAULT_SUMMARY,
},
}
export default meta
type Story = StoryObj<typeof SweepPreconditionAlert>
export const Default: Story = {}
export const MissingUtxos: Story = {
args: {
summary: {
...DEFAULT_SUMMARY,
numberOfMissingUtxos: 3,
},
},
}
export const MissingConfirmations: Story = {
args: {
summary: {
...DEFAULT_SUMMARY,
numberOfMissingConfirmations: 3,
},
},
}
export const NonFrozenFidelityBondsPresent: Story = {
args: {
summary: {
...DEFAULT_SUMMARY,
numberOfNonFrozenFidelityBondOutputs: 3,
},
},
}
export const RetryLockedUtxos: Story = {
args: {
summary: {
...DEFAULT_SUMMARY,
retryLockedUtxos: [
makeUtxo({
mixdepth: 0,
value: 21,
}),
makeUtxo({
mixdepth: 1,
value: 21_000,
}),
makeUtxo({
mixdepth: 2,
value: 21_000_000,
}),
],
},
},
}
export const Multiple: Story = {
args: {
summary: {
...DEFAULT_SUMMARY,
numberOfMissingUtxos: 3,
numberOfMissingConfirmations: 3,
numberOfNonFrozenFidelityBondOutputs: 3,
retryLockedUtxos: [
makeUtxo({
mixdepth: 0,
value: 21,
}),
makeUtxo({
mixdepth: 1,
value: 21_000,
}),
makeUtxo({
mixdepth: 2,
value: 21_000_000,
}),
],
},
},
}

View file

@ -0,0 +1,52 @@
import { vi } from 'vitest'
const OriginalIntlNumberFormat = Intl.NumberFormat
/**
* Runs `callback` with `Number.prototype.toLocaleString` and `Intl.NumberFormat`
* mocked so that calls without an explicit locale argument format using
* `locale` instead of the host machine's default locale. Calls that already
* pass an explicit locale are left untouched. This keeps locale-dependent
* assertions deterministic across contributors' machines and CI without
* pinning a locale in app code.
*/
export function withRuntimeLocale(locale: string, callback: () => Promise<void>): Promise<void>
export function withRuntimeLocale(locale: string, callback: () => void): void
export function withRuntimeLocale(locale: string, callback: () => void | Promise<void>): void | Promise<void> {
/* eslint-disable unicorn/no-this-outside-of-class -- mocking Number.prototype.toLocaleString requires `this` */
const numberToLocaleStringMock = vi.spyOn(Number.prototype, 'toLocaleString').mockImplementation(function (
this: number,
locales,
options,
) {
return Intl.NumberFormat(locales ?? locale, options).format(this)
})
/* eslint-enable unicorn/no-this-outside-of-class */
const intlNumberFormatMock = vi.spyOn(Intl, 'NumberFormat').mockImplementation(function (
locales?: string | string[],
options?: Intl.NumberFormatOptions,
) {
return new OriginalIntlNumberFormat(locales ?? locale, options)
} as unknown as typeof Intl.NumberFormat)
const restore = () => {
numberToLocaleStringMock.mockRestore()
intlNumberFormatMock.mockRestore()
}
let result: void | Promise<void>
try {
result = callback()
} catch (error) {
restore()
throw error
}
if (result instanceof Promise) {
return result.finally(restore)
}
restore()
return undefined
}

View file

@ -78,6 +78,18 @@ export default defineConfig((config): UserConfig => {
'@': path.resolve(import.meta.dirname, './src'),
},
},
build: {
rolldownOptions: {
output: {
minify: {
compress: {
dropConsole: buildOrPreview,
dropDebugger: buildOrPreview,
},
},
},
},
},
server: {
...server,
open: true,