diff --git a/.env.development b/.env.development index 3a37e0c6..9b4b3df4 100644 --- a/.env.development +++ b/.env.development @@ -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 diff --git a/docker/regtest/docker-compose-common.yml b/docker/regtest/docker-compose-common.yml index 1ed04619..0bd00c63 100644 --- a/docker/regtest/docker-compose-common.yml +++ b/docker/regtest/docker-compose-common.yml @@ -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 diff --git a/docker/regtest/docker-compose.yml b/docker/regtest/docker-compose.yml index 319892e0..9adaeeca 100644 --- a/docker/regtest/docker-compose.yml +++ b/docker/regtest/docker-compose.yml @@ -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: diff --git a/docs/developing.md b/docs/developing.md index e796c92c..1355b4c7 100644 --- a/docs/developing.md +++ b/docs/developing.md @@ -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 ``` diff --git a/package-lock.json b/package-lock.json index 38a9fe80..e386f3e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/package.json b/package.json index 0bb798fa..5953be25 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/App.tsx b/src/App.tsx index e1fb0c4e..d3887a78 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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={} /> + } /> {isDeveloperMode && isDebugFeatureEnabled('devPage') && ( ({ 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: () =>
, +})) + vi.mock('@/components/ui/jam/Balance', () => ({ Balance: ({ valueString, onClick }: { valueString: string; onClick?: () => void }) => ( {valueString} @@ -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() + 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() + }) }) diff --git a/src/components/MainWalletPage.tsx b/src/components/MainWalletPage.tsx index a239142f..68b8aa9b 100644 --- a/src/components/MainWalletPage.tsx +++ b/src/components/MainWalletPage.tsx @@ -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() 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 ? ( + + ) : null} +

@@ -87,8 +97,37 @@ export default function MainWalletPage({ walletFileName }: MainWalletPageProps) {t('current_wallet.button_withdraw')}

+ + {previewFeatures?.['tx-history'] === true ? ( +
+
+ +
+
+ ) : null}
+ {isHistoryExpanded && ( + setIsTxHistoryOpen(true)} + className="w-full max-w-6xl" + /> + )} + {error ? ( diff --git a/src/components/earn/CreateFidelityBondDialog/useCreateFidelityBondWizard.ts b/src/components/earn/CreateFidelityBondDialog/useCreateFidelityBondWizard.ts index 8170b344..b19b0f49 100644 --- a/src/components/earn/CreateFidelityBondDialog/useCreateFidelityBondWizard.ts +++ b/src/components/earn/CreateFidelityBondDialog/useCreateFidelityBondWizard.ts @@ -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(() => { diff --git a/src/components/earn/EarnPage.test.tsx b/src/components/earn/EarnPage.test.tsx index 2c4dcaa7..b3d781a7 100644 --- a/src/components/earn/EarnPage.test.tsx +++ b/src/components/earn/EarnPage.test.tsx @@ -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 } + }) => (
offer-card:{nickname} + orderbook-status:{orderbookStatus} + bond-value:{orderbookOffer?.fidelity_bond_value} + bond-amount:{fidelityBond?.amount} {children}
), @@ -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() + + 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() + + 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() + + 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() @@ -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() diff --git a/src/components/earn/EarnPage.tsx b/src/components/earn/EarnPage.tsx index 3bc0173b..14746cd7 100644 --- a/src/components/earn/EarnPage.tsx +++ b/src/components/earn/EarnPage.tsx @@ -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} >
{!!value?.txfee && ( -
-
+
+ +
@@ -109,6 +155,45 @@ export function OfferCard({ className, value, nickname, children }: PropsWithChi
)} + {fidelityBond !== undefined && ( +
+ 0, + 'text-brand-warning': bondWarning, + })} + /> +
+ +
+
+ + {t('earn.current.text_bond_value')}: {Math.floor(bondValue).toLocaleString()} + +
+
+ + + {t('earn.current.text_bond_locktime', { + date: new Date(fidelityBond.locktime * 1_000).toLocaleDateString(undefined, { + year: 'numeric', + month: 'long', + day: 'numeric', + }), + })} + +
+
+
+
+ )} {children} diff --git a/src/components/earn/fidelity-bond/useFidelityBondSweep.test.ts b/src/components/earn/fidelity-bond/useFidelityBondSweep.test.ts new file mode 100644 index 00000000..ca985da0 --- /dev/null +++ b/src/components/earn/fidelity-bond/useFidelityBondSweep.test.ts @@ -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 }) => ({ + 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()), + useJamWalletInfoContext: () => mocks.walletInfo, +})) + +vi.mock('@/lib/utils', async (importOriginal) => ({ + ...(await importOriginal()), + delayedPromise: vi.fn(() => Promise.resolve()), +})) + +const utxo = (overrides: Partial): 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 => ({ + ...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 } }), + ) + }) +}) diff --git a/src/components/earn/fidelity-bond/useFidelityBondSweep.ts b/src/components/earn/fidelity-bond/useFidelityBondSweep.ts index 340f889f..80c877e8 100644 --- a/src/components/earn/fidelity-bond/useFidelityBondSweep.ts +++ b/src/components/earn/fidelity-bond/useFidelityBondSweep.ts @@ -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, diff --git a/src/components/earn/report/EarnReportContent.test.tsx b/src/components/earn/report/EarnReportContent.test.tsx index 63a6139a..11955426 100644 --- a/src/components/earn/report/EarnReportContent.test.tsx +++ b/src/components/earn/report/EarnReportContent.test.tsx @@ -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() 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() 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() + + 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() + + 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() + + 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() + 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() }) }) diff --git a/src/components/earn/report/EarnReportContent.tsx b/src/components/earn/report/EarnReportContent.tsx index cc45759c..6e6cf12b 100644 --- a/src/components/earn/report/EarnReportContent.tsx +++ b/src/components/earn/report/EarnReportContent.tsx @@ -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 {/* Toolbar: search + refresh */} -
-
- - table.setGlobalFilter(event.target.value)} - className="pl-8" - /> +
+
+ + table.setGlobalFilter(event.target.value)} + placeholder={t('earn.report.placeholder_search')} + /> + + + + + + + + + +
- - {globalFilter === '' @@ -236,7 +259,7 @@ export const EarnReportContent = ({ className, enabled }: EarnReportContentProps : t('earn.report.text_report_summary_filtered', { count: table.getFilteredRowModel().rows.length })} {isDeveloperMode ? ( - - )} -
+ + + -
{/* Search match count */} diff --git a/src/components/logging/useJmwalletdStdoutLog.ts b/src/components/logging/useJmwalletdStdoutLog.ts index 692cc041..ba49de08 100644 --- a/src/components/logging/useJmwalletdStdoutLog.ts +++ b/src/components/logging/useJmwalletdStdoutLog.ts @@ -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, diff --git a/src/components/login/LoginCard.tsx b/src/components/login/LoginCard.tsx index 934ddeca..410f8096 100644 --- a/src/components/login/LoginCard.tsx +++ b/src/components/login/LoginCard.tsx @@ -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 = ({ <> -
- {listWalletsFetching ? ( - - ) : ( +
+
+ + + + - )} +
{t('login.title')} {listWalletsLoading ? ( @@ -103,7 +118,7 @@ export const LoginCard = ({ ) : null} - + {listWalletsError ? ( <> diff --git a/src/components/send/ActiveCollaborativeSendAlert.test.tsx b/src/components/send/ActiveCollaborativeSendAlert.test.tsx new file mode 100644 index 00000000..251750e6 --- /dev/null +++ b/src/components/send/ActiveCollaborativeSendAlert.test.tsx @@ -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) => (options ? `${key}:${JSON.stringify(options)}` : key), + }), + Trans: ({ i18nKey, children }: { i18nKey: string; children?: React.ReactNode }) => {i18nKey || children}, +})) + +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( + , + ) + + 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( + , + ) + + 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( + , + ) + + await user.click(screen.getByRole('button', { name: 'global.abort' })) + expect(onAbort).toHaveBeenCalledTimes(1) + }) + + it('disables abort button when stopping coinjoin', () => { + render( + , + ) + + expect(screen.getByRole('button', { name: 'global.abort' })).toBeDisabled() + }) +}) diff --git a/src/components/send/ActiveCollaborativeSendAlert.tsx b/src/components/send/ActiveCollaborativeSendAlert.tsx new file mode 100644 index 00000000..ba71683c --- /dev/null +++ b/src/components/send/ActiveCollaborativeSendAlert.tsx @@ -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 ( + <> + + +
+ {t('send.text_coinjoin_already_running')} + +
+
+ + +
+ +
+ {data && ( + <> +
+ {sourceJar ? ( +
+ +
+ + + {sourceJar.name} #{sourceJar.jarIndex.toLocaleString()} + +
+
+ ) : null} + +
+ +
+ + {data.amount.isSweep ? ( + + ) : ( + + )} +
+
+ + {data.numCollaborators !== undefined ? ( +
+ +
+ + {data.numCollaborators.toLocaleString()} +
+
+ ) : null} + +
+ +
+ + {destinationJar && ( + + {destinationJar.name}{' '} + #{destinationJar.jarIndex.toLocaleString()} + + )} +
+
+
+
+ + )} +
+ + + + + + +
+ + ) +} diff --git a/src/components/send/SendCoinjoinPreconditionAlert.tsx b/src/components/send/SendCoinjoinPreconditionAlert.tsx index e02e8f92..3a470de9 100644 --- a/src/components/send/SendCoinjoinPreconditionAlert.tsx +++ b/src/components/send/SendCoinjoinPreconditionAlert.tsx @@ -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 ( - <> - - ), - }} - /> -
-
{t('global.utxos')}
-
    - {summary.retryLockedUtxos.map((utxo) => ( -
  • - - - - {shortenStringMiddle(utxo.utxo, 26)} - -
  • - ))} -
-
- - ) -} - export const SendCoinjoinPreconditionAlert = ({ summary }: SendCoinjoinPreconditionAlertProps) => { - const { t } = useTranslation() - - if (summary.isFulfilled) { - return null - } - - return ( - - - {t('send.coinjoin_precondition.title')} - -
    - {summary.numberOfNonFrozenFidelityBondOutputs === 0 ? null : ( -
  • - , - }} - /> -
  • - )} - {summary.numberOfMissingUtxos === 0 ? null : ( -
  • - {t('send.coinjoin_precondition.hint_missing_utxos', { - minConfirmations: summary.options.minConfirmations, - })} -
  • - )} - {summary.numberOfMissingConfirmations === 0 ? null : ( -
  • - {t('send.coinjoin_precondition.hint_missing_confirmations', { - minConfirmations: summary.options.minConfirmations, - amountOfMissingConfirmations: summary.numberOfMissingConfirmations, - })} -
  • - )} - {summary.retryLockedUtxos.length === 0 ? null :
  • {}
  • } -
-
-
- ) + return } diff --git a/src/components/send/SendForm.test.tsx b/src/components/send/SendForm.test.tsx index 10f4a0a3..aedc0406 100644 --- a/src/components/send/SendForm.test.tsx +++ b/src/components/send/SendForm.test.tsx @@ -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 }) => {i18nKey}, useTranslation: () => ({ t: (key: string, options?: Record) => 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( + , + ) + 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() + }) }) diff --git a/src/components/send/SendForm.tsx b/src/components/send/SendForm.tsx index bad1016f..d102b020 100644 --- a/src/components/send/SendForm.tsx +++ b/src/components/send/SendForm.tsx @@ -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 && ( )} + {isCoinJoin && !orderbookCheckIsLoading && !orderbookCheckIsError && !hasOrders && ( + + )}
diff --git a/src/components/send/SendPage.test.tsx b/src/components/send/SendPage.test.tsx index d7ec0b85..10cc9b8e 100644 --- a/src/components/send/SendPage.test.tsx +++ b/src/components/send/SendPage.test.tsx @@ -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) => (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() diff --git a/src/components/send/SendPage.tsx b/src/components/send/SendPage.tsx index 60c49ad4..a6e01cd4 100644 --- a/src/components/send/SendPage.tsx +++ b/src/components/send/SendPage.tsx @@ -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 } @@ -410,7 +413,7 @@ export const SendPage = ({ walletFileName }: SendPageProps) => { {feeConfigValidation.maxFeesConfigMissing && ( setShowFeeConfigDialog(true)} className="mb-4" /> )} - {jmSession?.maker_running === true && ( + {makerRunning === true && ( {t('send.text_maker_running')} @@ -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 ? ( - <> - - - {t('send.alert_collaborative_awaiting_completion')} + currentPaymentAttempt?.data.isCoinJoin === true && !isWaitingCoinjoinStart && takerRunning === false && ( + <> + {walletInfoIsFetching ? ( + <> + + + {t('send.alert_collaborative_awaiting_completion')} + + + ) : ( + <> + {currentPaymentAttempt.utxosHashHex === utxosHashHex ? ( + + + {t('send.alert_collaborative_ended_title')} + +
{t('send.alert_collaborative_ended_description')}
+
+ +
+
- - ) : ( - <> - {currentPaymentAttempt.utxosHashHex === utxosHashHex ? ( - - - {t('send.alert_collaborative_ended_title')} - -
{t('send.alert_collaborative_ended_description')}
-
- -
-
-
- ) : ( - - - {t('send.alert_collaborative_completed_title')} - -
{t('send.alert_collaborative_completed_description')}
-
- -
-
-
- )} - - )} - - ) + ) : ( + + + {t('send.alert_collaborative_completed_title')} + +
{t('send.alert_collaborative_completed_description')}
+
+ +
+
+
+ )} + + )} + + ) } - {takerRunning && !isWaitingCoinjoinStop && ( + + {schedulerRunning && ( {t('send.text_coinjoin_already_running')} - - {currentPaymentAttempt && ( -
-                  {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,
-                  )}
-                
- )} - {takerRunning && !schedulerRunning ? ( -
- -
- ) : null} -
)} + {takerRunning && !schedulerRunning && ( + setShowAbortCoinjoinDialog(true)} + /> + )} + {triggerNonCollaborativeTransaction.error ? ( @@ -586,7 +564,11 @@ export const SendPage = ({ walletFileName }: SendPageProps) => { )} - + { addressSummary={addressSummary} walletBalanceSummary={walletBalanceSummary} disabled={ - jmSession?.maker_running === true || + makerRunning || takerRunning || rescanInfo.rescanning || isWaitingCoinjoinStart || diff --git a/src/components/settings/LanguageSelector.tsx b/src/components/settings/LanguageSelector.tsx deleted file mode 100644 index f65dd5d2..00000000 --- a/src/components/settings/LanguageSelector.tsx +++ /dev/null @@ -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 ( -
-
-
- -
-
-

{t('settings.label_select_language')}

-
-
- - -
- ) -} diff --git a/src/components/settings/RescanChainPage.test.tsx b/src/components/settings/RescanChainPage.test.tsx index 0958d730..1e4c52df 100644 --- a/src/components/settings/RescanChainPage.test.tsx +++ b/src/components/settings/RescanChainPage.test.tsx @@ -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 () => { diff --git a/src/components/settings/SeedPhraseDialog.tsx b/src/components/settings/SeedPhraseDialog.tsx index 146ade70..bddd616d 100644 --- a/src/components/settings/SeedPhraseDialog.tsx +++ b/src/components/settings/SeedPhraseDialog.tsx @@ -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 = ({
diff --git a/src/components/settings/SettingsItem.test.tsx b/src/components/settings/SettingsItem.test.tsx new file mode 100644 index 00000000..f2edd7e3 --- /dev/null +++ b/src/components/settings/SettingsItem.test.tsx @@ -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 + }) => ( + + ), +})) + +describe('SettingsItem', () => { + beforeEach(() => { + mocks.navigate.mockReset() + mocks.open.mockReset() + }) + + it('renders title and icon', () => { + render() + + expect(screen.getByText('settings.show_seed')).toBeInTheDocument() + }) + + it('exposes an actionable row as a button', () => { + render() + + expect(screen.getByRole('button', { name: 'settings.show_seed' })).toBeInTheDocument() + }) + + it('does not render a button without an action', () => { + render() + + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) + + it('is reachable by keyboard', async () => { + render() + + await user.tab() + + expect(screen.getByRole('button', { name: 'settings.show_seed' })).toHaveFocus() + }) + + it('triggers the action on Enter', async () => { + const action = vi.fn() + render() + + await user.tab() + await user.keyboard('{Enter}') + + expect(action).toHaveBeenCalledOnce() + }) + + it('triggers the action on Space', async () => { + const action = vi.fn() + render() + + await user.tab() + await user.keyboard(' ') + + expect(action).toHaveBeenCalledOnce() + }) + + it('triggers the action on click', async () => { + const action = vi.fn() + render() + + 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() + + 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( + + + , + ) + + // 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() + + 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() + + 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( + , + ) + + 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( + , + ) + + await user.tab() + expect(screen.getByRole('button', { name: 'switch:false' })).toHaveFocus() + + await user.keyboard('{Enter}') + expect(onCheckedChange).toHaveBeenCalledWith(true) + }) +}) diff --git a/src/components/settings/SettingsItem.tsx b/src/components/settings/SettingsItem.tsx index e92e70be..7c4ba1df 100644 --- a/src/components/settings/SettingsItem.tsx +++ b/src/components/settings/SettingsItem.tsx @@ -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 + /** + * 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 = ( -
void action() : undefined} - > + <>
{Icon && } @@ -39,13 +52,34 @@ export const SettingItem = ({
{children !== undefined &&
{children}
} -
+ ) - 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 ( +
+ +
+ ) + } + + return ( +
void action() : undefined}> + {content} +
+ ) } -type SettingsLinkProps = Omit & { +type SettingsLinkProps = Omit & { to: string external?: boolean } @@ -54,7 +88,7 @@ export const SettingsLink = ({ to, external = false, ...props }: SettingsLinkPro const navigate = useNavigate() return ( - { if (external) { @@ -65,19 +99,22 @@ export const SettingsLink = ({ to, external = false, ...props }: SettingsLinkPro }} > {external && } - +
) } -type SettingsSwitchProps = Omit & { +type SettingsSwitchProps = Omit & { 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 ( - 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. + onCheckedChange?.(!checked)} hasInteractiveChild={displayToggle}> {displayToggle && } - +
) } diff --git a/src/components/settings/SettingsPage.test.tsx b/src/components/settings/SettingsPage.test.tsx index 91c4684a..20e58cb3 100644 --- a/src/components/settings/SettingsPage.test.tsx +++ b/src/components/settings/SettingsPage.test.tsx @@ -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: () =>
language-selector
, })) diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index 93190ee7..3fba6fd4 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -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) {t('settings.section_title_display')} - - } 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} /> - - - + + + @@ -125,7 +130,7 @@ export const SettingsPage = ({ walletFileName, onLockWallet }: SettingPageProps) {t('settings.section_title_market')} - setShowFeeConfigDialog(true)} @@ -139,21 +144,21 @@ export const SettingsPage = ({ walletFileName, onLockWallet }: SettingPageProps) {t('settings.section_title_wallet')} - setShowSeedDialog(true)} disabled={hashedPassword === undefined} /> - setShowXpubsDialog(true)} disabled={hashedPassword === undefined} /> - lockWalletMutation.isPending ? ( @@ -214,10 +219,39 @@ export const SettingsPage = ({ walletFileName, onLockWallet }: SettingPageProps) to={JAM_REPO_URL} external={true} /> + + { + jamSettings.update({ previewFeatures: checked ? {} : undefined }) + }} + /> + {!!jamSettings.state.previewFeatures && ( + <> + + { + jamSettings.update({ + previewFeatures: { + ...jamSettings.state.previewFeatures, + 'tx-history': checked, + }, + }) + }} + /> + + )} {isDevMode() && ( <> - ({ 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 => { 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() + + 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() + expect(screen.queryByText('orderbook.alert_precheck_empty_title')).not.toBeInTheDocument() + + mocks.orderbookIsLoading = false + mocks.orderbookError = true + rerender() + expect(screen.queryByText('orderbook.alert_precheck_empty_title')).not.toBeInTheDocument() + }) + }) }) diff --git a/src/components/sweep/SweepPage.tsx b/src/components/sweep/SweepPage.tsx index d12bfa81..bbc95e4c 100644 --- a/src/components/sweep/SweepPage.tsx +++ b/src/components/sweep/SweepPage.tsx @@ -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 = { time_lambda_seconds: 10, stage1_wait_multiplier: 1.5, @@ -62,7 +58,14 @@ const INSECURE_SCHEDULE_TUMBLER_OPTIONS: Partial = { 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() const [alertMessage, setAlertMessage] = useState() + 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) => { setShowFeeConfigDialog(true)} className="mb-4" /> )} + {!orderbookCheckIsLoading && !orderbookCheckIsError && !hasOrders && ( + + )} + {alertMessage && ( {t('global.error')} @@ -295,14 +309,14 @@ export const SweepPage = ({ walletFileName }: SweepPageProps) => { {singleCoinJoinRunning && ( - {t('send.text_coinjoin_already_running')} + {t('send.text_coinjoin_already_running')} )} {makerRunning && ( - {t('send.text_maker_running')} + {t('send.text_maker_running')} )} @@ -436,7 +450,11 @@ export const SweepPage = ({ walletFileName }: SweepPageProps) => { {!currentSchedule && !schedulerRunning && ( <> - +
diff --git a/src/components/sweep/SweepPreconditionAlert.tsx b/src/components/sweep/SweepPreconditionAlert.tsx index afb1440e..fc5c6eb5 100644 --- a/src/components/sweep/SweepPreconditionAlert.tsx +++ b/src/components/sweep/SweepPreconditionAlert.tsx @@ -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 ( - <> - - ), - }} - /> -
-
{t('global.utxos')}
-
    - {summary.retryLockedUtxos.map((utxo) => ( -
  • - - - - {shortenStringMiddle(utxo.utxo, 26)} - -
  • - ))} -
-
- - ) -} - export const SweepPreconditionAlert = ({ summary }: SweepPreconditionAlertProps) => { - const { t } = useTranslation() - - if (summary.isFulfilled) { - return null - } - - return ( - - - {t('scheduler.precondition.title')} - -
    - {summary.numberOfNonFrozenFidelityBondOutputs === 0 ? null : ( -
  • - , - }} - /> -
  • - )} - {summary.numberOfMissingUtxos === 0 ? null : ( -
  • - {t('scheduler.precondition.hint_missing_utxos', { - minConfirmations: summary.options.minConfirmations, - })} -
  • - )} - {summary.numberOfMissingConfirmations === 0 ? null : ( -
  • - {t('scheduler.precondition.hint_missing_confirmations', { - minConfirmations: summary.options.minConfirmations, - amountOfMissingConfirmations: summary.numberOfMissingConfirmations, - })} -
  • - )} - {summary.retryLockedUtxos.length === 0 ? null :
  • {}
  • } -
-
-
- ) + return } diff --git a/src/components/ui/button-variants.ts b/src/components/ui/button-variants.ts index ee782c92..ec705830 100644 --- a/src/components/ui/button-variants.ts +++ b/src/components/ui/button-variants.ts @@ -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: { diff --git a/src/components/ui/jam/Balance.test.tsx b/src/components/ui/jam/Balance.test.tsx index b5c8ce40..49d0355a 100644 --- a/src/components/ui/jam/Balance.test.tsx +++ b/src/components/ui/jam/Balance.test.tsx @@ -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) => { const providers = ({ children }: { children: React.ReactNode }) => { @@ -35,11 +37,13 @@ describe('', () => { }) it('should render balance in SATS', () => { - render() - 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() + 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('', () => { }) it('should render a string BTC value correctly as SATS', () => { - render() - expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`12,303,224,961`) - expect(screen.getByTestId('sats-symbol')).toBeVisible() + withRuntimeLocale('en-US', () => { + render() + 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('', () => { }) it('should render a large string BTC value correctly as BTC', () => { - render() - expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`20,999,999.97690000`) + withRuntimeLocale('en-US', () => { + render() + expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`20,999,999.97690000`) + }) }) it('should render a large string BTC value correctly as SATS', () => { - render() - expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`2,099,999,997,690,000`) + withRuntimeLocale('en-US', () => { + render() + expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`2,099,999,997,690,000`) + }) }) it('should render a max string BTC value correctly as BTC', () => { - render() - expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`21,000,000.00000000`) + withRuntimeLocale('en-US', () => { + render() + expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`21,000,000.00000000`) + }) }) it('should render a max string BTC value correctly as SATS', () => { - render() - expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`2,100,000,000,000,000`) + withRuntimeLocale('en-US', () => { + render() + 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('', () => { }) it('should render a large string SATS value correctly as BTC', () => { - render() - expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`20,999,999.97690000`) + withRuntimeLocale('en-US', () => { + render() + expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`20,999,999.97690000`) + }) }) it('should render a large string SATS value correctly as SATS', () => { - render() - expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`2,099,999,997,690,000`) + withRuntimeLocale('en-US', () => { + render() + expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`2,099,999,997,690,000`) + }) }) it('should render a max string SATS value correctly as BTC', () => { - render() - expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`21,000,000.00000000`) + withRuntimeLocale('en-US', () => { + render() + expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`21,000,000.00000000`) + }) }) it('should render a max string SATS value correctly as SATS', () => { - render() - expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`2,100,000,000,000,000`) + withRuntimeLocale('en-US', () => { + render() + expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`2,100,000,000,000,000`) + }) }) it('should render frozen balance in BTC', () => { @@ -132,10 +154,12 @@ describe('', () => { }) it('should render frozen balance in SATS', () => { - render() - 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() + 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('', () => { 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() + + 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() + + 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() + + 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() + + 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() + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) }) diff --git a/src/components/ui/jam/Balance.tsx b/src/components/ui/jam/Balance.tsx index 11740b9a..c61215cd 100644 --- a/src/components/ui/jam/Balance.tsx +++ b/src/components/ui/jam/Balance.tsx @@ -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 ( - + const sharedClassName = cn( + 'balance-hook inline-flex items-center', + { + 'text-brand-info': frozen, + }, + className, + ) + + const content = ( + <> {children} {showSymbol && symbol} {frozen && frozenSymbol && FROZEN_SYMBOL} - + ) + + if (onClick) { + return ( + + ) + } + + return {content} } 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(() => { 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) => { - onClickHandler?.(event) - props.onClick?.(event) - }, + onClick: isInteractive + ? (event: MouseEvent) => { + 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') { diff --git a/src/components/ui/jam/CookingPotIcon.tsx b/src/components/ui/jam/CookingPotIcon.tsx new file mode 100644 index 00000000..1dd2d288 --- /dev/null +++ b/src/components/ui/jam/CookingPotIcon.tsx @@ -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 = { + 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 ( +
+ + +
+ +
+
+ ) +} diff --git a/src/components/settings/LanguageSelector.test.tsx b/src/components/ui/jam/LanguageSelector.test.tsx similarity index 93% rename from src/components/settings/LanguageSelector.test.tsx rename to src/components/ui/jam/LanguageSelector.test.tsx index a0a590e2..c1b71220 100644 --- a/src/components/settings/LanguageSelector.test.tsx +++ b/src/components/ui/jam/LanguageSelector.test.tsx @@ -38,6 +38,8 @@ vi.mock('@/components/ui/select', () => ({ SelectTrigger: ({ children }: ChildrenProps) =>
{children}
, SelectValue: ({ placeholder }: { placeholder?: string }) =>
{placeholder}
, SelectContent: ({ children }: ChildrenProps) =>
{children}
, + SelectGroup: ({ children }: ChildrenProps) =>
{children}
, + SelectLabel: ({ children }: ChildrenProps) =>
{children}
, SelectItem: ({ children, value }: ChildrenProps & { value: string }) => (
{children}
), diff --git a/src/components/ui/jam/LanguageSelector.tsx b/src/components/ui/jam/LanguageSelector.tsx new file mode 100644 index 00000000..d0553a45 --- /dev/null +++ b/src/components/ui/jam/LanguageSelector.tsx @@ -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 & { className?: string } = {}) => { + const { i18n, t } = useTranslation() + const currentLanguage = languages.find((lang) => lang.key === i18n.resolvedLanguage) + const currentLanguageDescription = currentLanguage?.description || 'English' + + return ( + + ) +} + +export const LanguageSelectorDropdownMenu = ({ + className, + align, + loop = true, + ...props +}: PropsWithChildren< + ComponentProps & { + className?: string + align?: ComponentProps['align'] + loop?: ComponentProps['loop'] + } +> = {}) => { + const { i18n, t } = useTranslation() + + return ( + + + {props.children ?? ( + + )} + + + + + + {t('settings.label_select_language')} + + { + void i18n.changeLanguage(value) + }} + > + {languages.map((language) => ( + + {language.description} + + ))} + + + + + ) +} diff --git a/src/components/ui/jam/OrderbookEmptyAlert.test.tsx b/src/components/ui/jam/OrderbookEmptyAlert.test.tsx new file mode 100644 index 00000000..aae12d42 --- /dev/null +++ b/src/components/ui/jam/OrderbookEmptyAlert.test.tsx @@ -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 }) => ( + + {i18nKey} + {components?.['1']} + + ), +})) + +vi.mock('react-router-dom', () => ({ + Link: ({ children, to }: { children?: ReactNode; to: string }) => {children}, +})) + +describe('OrderbookEmptyAlert', () => { + it('renders title, description, button and link to orderbook page', () => { + render( {}} />) + + 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( {}} />) + + 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() + }) +}) diff --git a/src/components/ui/jam/OrderbookEmptyAlert.tsx b/src/components/ui/jam/OrderbookEmptyAlert.tsx new file mode 100644 index 00000000..95de8d79 --- /dev/null +++ b/src/components/ui/jam/OrderbookEmptyAlert.tsx @@ -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 +} + +export const OrderbookEmptyAlert = ({ className, isChecking, onCheckClick }: OrderbookEmptyAlertProps) => { + const { t } = useTranslation() + + return ( + + + {t('orderbook.alert_precheck_empty_title')} + +

+ , + }} + /> +

+
+ {onCheckClick && ( + + )} +
+
+
+ ) +} diff --git a/src/components/ui/jam/PreconditionAlert.tsx b/src/components/ui/jam/PreconditionAlert.tsx new file mode 100644 index 00000000..ce42d921 --- /dev/null +++ b/src/components/ui/jam/PreconditionAlert.tsx @@ -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 ( + <> + + ), + }} + /> +
+
{t('global.utxos')}
+
    + {summary.retryLockedUtxos.map((utxo) => ( +
  • + + + + {shortenStringMiddle(utxo.utxo, 26)} + +
  • + ))} +
+
+ + ) +} + +export const PreconditionAlert = ({ summary, i18nPrefix }: PreconditionAlertProps) => { + const { t } = useTranslation() + + if (summary.isFulfilled) { + return null + } + + return ( + + + {t(`${i18nPrefix}.title`)} + +
    + {summary.numberOfNonFrozenFidelityBondOutputs === 0 ? null : ( +
  • + , + }} + /> +
  • + )} + {summary.numberOfMissingUtxos === 0 ? null : ( +
  • + {t(`${i18nPrefix}.hint_missing_utxos`, { + minConfirmations: summary.options.minConfirmations, + })} +
  • + )} + {summary.numberOfMissingConfirmations === 0 ? null : ( +
  • + {t(`${i18nPrefix}.hint_missing_confirmations`, { + minConfirmations: summary.options.minConfirmations, + amountOfMissingConfirmations: summary.numberOfMissingConfirmations, + })} +
  • + )} + {summary.retryLockedUtxos.length === 0 ? null : ( +
  • + +
  • + )} +
+
+
+ ) +} diff --git a/src/components/wallet/TxHistoryContent.test.tsx b/src/components/wallet/TxHistoryContent.test.tsx new file mode 100644 index 00000000..b22a61a9 --- /dev/null +++ b/src/components/wallet/TxHistoryContent.test.tsx @@ -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 }) => ( +
+ Mock Table (Rows: {history.length}) +
+ ), +})) + +vi.mock('@/components/ui/alert', () => ({ + Alert: ({ children, variant }: { children: React.ReactNode; variant?: string }) => ( +
+ {children} +
+ ), + AlertDescription: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) + +vi.mock('@/components/ui/spinner', () => ({ + Spinner: () =>
, +})) + +const mockUseQuery = useQueryWalletHistory as unknown as ReturnType + +const mockQueryResult = (overrides: Partial>) => + ({ + isLoading: false, + error: null, + isFetching: false, + refetch: vi.fn(), + ...overrides, + }) as UseQueryResult + +describe('TxHistoryContent', () => { + it('renders loading state', () => { + mockUseQuery.mockReturnValue({ + history: [], + queryResult: mockQueryResult({ isLoading: true }), + }) + + render() + expect(screen.getByTestId('mock-loading')).toBeInTheDocument() + }) + + it('renders error state', () => { + mockUseQuery.mockReturnValue({ + history: [], + queryResult: mockQueryResult({ error: new Error('failed') }), + }) + + render() + 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() + 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() + + 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() + expect(screen.queryByRole('button', { name: 'tx_history.button_load_more' })).not.toBeInTheDocument() + }) +}) diff --git a/src/components/wallet/TxHistoryContent.tsx b/src/components/wallet/TxHistoryContent.tsx new file mode 100644 index 00000000..f73118e4 --- /dev/null +++ b/src/components/wallet/TxHistoryContent.tsx @@ -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([]) + const utxosHashHex = useSafeUtxosHashHex() + + const { history, queryResult } = useQueryWalletHistory({ + walletFileName, + limit, + enabled, + utxosHashHex, + }) + + const combinedHistory = useMemo(() => [...demoEntries, ...history], [demoEntries, history]) + + return ( +
+
+

{t('tx_history.section_title')}

+
+ {isDeveloperMode && ( + + )} + + + + {compact && onViewAll ? ( + + ) : null} +
+
+ + {queryResult.isLoading ? ( +
+ + {t('global.loading')} +
+ ) : queryResult.error ? ( + + + {t('tx_history.error_loading')} + + ) : ( + + )} + + {!compact && combinedHistory.length >= limit ? ( +
+ +
+ ) : null} +
+ ) +} diff --git a/src/components/wallet/TxHistoryOverlay.test.tsx b/src/components/wallet/TxHistoryOverlay.test.tsx new file mode 100644 index 00000000..f2f44194 --- /dev/null +++ b/src/components/wallet/TxHistoryOverlay.test.tsx @@ -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 }) => ( +
+ Content +
+ ), +})) + +vi.mock('../ui/jam/PageTitle', () => ({ + default: ({ title }: { title: string }) =>

{title}

, +})) + +// Mock Dialog to avoid dealing with portals and radix UI internals +vi.mock('@/components/ui/dialog', () => ({ + Dialog: ({ children, open, onOpenChange }: DialogProps) => + open ? ( +
+ + {children} +
+ ) : null, + DialogContent: ({ children }: ChildrenProps) =>
{children}
, + DialogHeader: ({ children }: ChildrenProps) =>
{children}
, + DialogTitle: ({ children }: ChildrenProps) =>
{children}
, +})) + +describe('TxHistoryOverlay', () => { + it('renders dialog content when open', () => { + render() + + 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() + + const closeButton = screen.getByText('Close') + fireEvent.click(closeButton) + + expect(onOpenChange).toHaveBeenCalledWith(false) + }) + + it('does not render when closed', () => { + render() + expect(screen.queryByTestId('dialog')).not.toBeInTheDocument() + }) +}) diff --git a/src/components/wallet/TxHistoryOverlay.tsx b/src/components/wallet/TxHistoryOverlay.tsx new file mode 100644 index 00000000..6b77b12d --- /dev/null +++ b/src/components/wallet/TxHistoryOverlay.tsx @@ -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, 'children'>, + 'open' | 'onOpenChange' +> & + Pick, 'walletFileName'> + +export function TxHistoryOverlay({ open, onOpenChange, walletFileName, ...dialogProps }: TxHistoryOverlayProps) { + const { t } = useTranslation() + + return ( + onOpenChange(false)} {...dialogProps}> + + + + + + + +
+ +
+
+
+ ) +} diff --git a/src/components/wallet/TxHistoryPage.test.tsx b/src/components/wallet/TxHistoryPage.test.tsx new file mode 100644 index 00000000..c96405d0 --- /dev/null +++ b/src/components/wallet/TxHistoryPage.test.tsx @@ -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 }) => ( +
+ ), +})) + +describe('TxHistoryPage', () => { + it('renders TxHistoryContent in non-compact page mode with title', () => { + render() + + expect(screen.getByText('tx_history.title')).toBeInTheDocument() + const content = screen.getByTestId('tx-history-content') + expect(content).toBeInTheDocument() + expect(content).toHaveAttribute('data-compact', 'false') + }) +}) diff --git a/src/components/wallet/TxHistoryPage.tsx b/src/components/wallet/TxHistoryPage.tsx new file mode 100644 index 00000000..b7c2576f --- /dev/null +++ b/src/components/wallet/TxHistoryPage.tsx @@ -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 ( +
+ + +
+ ) +} diff --git a/src/components/wallet/TxHistoryTable.test.tsx b/src/components/wallet/TxHistoryTable.test.tsx new file mode 100644 index 00000000..f9dc23ac --- /dev/null +++ b/src/components/wallet/TxHistoryTable.test.tsx @@ -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 }) => {value}, +})) + +vi.mock('../ui/jam/Balance', () => ({ + Balance: ({ valueString }: { valueString: string }) => {valueString}, +})) + +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() + + 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() + expect(screen.getByText('tx_history.empty_history')).toBeInTheDocument() + }) + + it('expands row details to show raw JSON', () => { + render() + + 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() + + 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() + }) +}) diff --git a/src/components/wallet/TxHistoryTable.tsx b/src/components/wallet/TxHistoryTable.tsx new file mode 100644 index 00000000..8abb3ba5 --- /dev/null +++ b/src/components/wallet/TxHistoryTable.tsx @@ -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[0]['variant']> + +const ROLE_VARIANT: Record = { + maker: 'cj-out', + taker: 'cj-change', + send: 'used-empty', + deposit: 'deposit', +} + +const columnHelper = createColumnHelper() + +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 ( + + +
{JSON.stringify(entry, null, 2)}
+
+
+ ) +} + +const TxHistoryTableRow = ({ row }: { row: Row }) => { + return ( + <> + + {row.getVisibleCells().map((cell) => { + const alignCenter = cell.column.columnDef.meta?.align === 'center' + const alignRight = cell.column.columnDef.meta?.align === 'right' + return ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ) + })} + + {row.getIsExpanded() && ( + + + + + + )} + + ) +} + +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) => {formatDateTime(info.getValue())}, + 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 ( + {roleLabel(info.row.original, t)} + ) + }, + }), + 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) => , + 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) => , + 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 ? ( +
+ {shortenStringMiddle(txid, 16)} + } + successText={} + 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'))} + /> +
+ ) : ( + '' + ) + }, + enableSorting: false, + }), + { + id: 'expand-col', + cell: ({ row }: CellContext) => { + return row.getCanExpand() ? ( + + ) : ( + '' + ) + }, + enableSorting: false, + }, + ] +} + +interface TxHistoryTableProps { + history: HistoryEntry[] + compact?: boolean +} + +export const TxHistoryTable = ({ history, compact = false }: TxHistoryTableProps) => { + const { t } = useTranslation() + const [sorting, setSorting] = useState([{ id: 'timestamp', desc: true }]) + const [isShowAll, setIsShowAll] = useState(false) + const [pagination, setPagination] = useState({ + 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({ + 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 ( + + {t('tx_history.empty_history')} + + ) + } + + return ( +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {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 ( + header.column.toggleSorting() : undefined} + > +
0 && !header.column.getIsSorted(), + })} + > + {flexRender(header.column.columnDef.header, header.getContext())} + {canSort ? : undefined} +
+
+ ) + })} +
+ ))} +
+ + {table.getRowModel().rows.map((row) => ( + + ))} + +
+
+ + {!compact && ( + 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) + }} + /> + )} +
+ ) +} diff --git a/src/constants/jam.ts b/src/constants/jam.ts index 11c91024..13d62497 100644 --- a/src/constants/jam.ts +++ b/src/constants/jam.ts @@ -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( diff --git a/src/constants/routes.ts b/src/constants/routes.ts index 150fb238..d307413d 100644 --- a/src/constants/routes.ts +++ b/src/constants/routes.ts @@ -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', diff --git a/src/context/JamDisplayContextProvider.test.tsx b/src/context/JamDisplayContextProvider.test.tsx index acba726c..e2c2d2a4 100644 --- a/src/context/JamDisplayContextProvider.test.tsx +++ b/src/context/JamDisplayContextProvider.test.tsx @@ -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( - - - , - ) + withRuntimeLocale('en-US', () => { + render( + + + , + ) - 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', () => { diff --git a/src/context/JamWalletInfoContext.ts b/src/context/JamWalletInfoContext.ts index 70840d82..70b225d2 100644 --- a/src/context/JamWalletInfoContext.ts +++ b/src/context/JamWalletInfoContext.ts @@ -84,6 +84,7 @@ interface JamWalletInfoContextType { walletName: string | null walletBalanceSummary: WalletBalanceSummary maxJarAvailableBalance: AmountSats + hasEligibleFidelityBondUtxo: boolean fidelityBondSummary: FidelityBondSummary addressSummary: AddressSummary accountSummary: AccountSummary diff --git a/src/context/JamWalletInfoContextProvider.test.tsx b/src/context/JamWalletInfoContextProvider.test.tsx index 27738364..984aed9f 100644 --- a/src/context/JamWalletInfoContextProvider.test.tsx +++ b/src/context/JamWalletInfoContextProvider.test.tsx @@ -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 => ({ ...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('', () => { 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('', () => { expect(context?.error).toBeNull() }) + it('reports when an eligible fidelity-bond UTXO is available', () => { + let context: ReturnType | undefined + mocks.walletInfo = walletInfo('cj-out') + + render( + + (context = value)} /> + , + ) + + expect(context?.hasEligibleFidelityBondUtxo).toBe(true) + }) + it('handles malformed accounts, missing branches, and entries without a status', () => { let context: ReturnType | undefined mocks.walletInfo = { @@ -269,3 +285,92 @@ describe('', () => { 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( + + (captured = context.utxosHashHex)} /> + , + ) + 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)) + }) +}) diff --git a/src/context/JamWalletInfoContextProvider.tsx b/src/context/JamWalletInfoContextProvider.tsx index 430bfa21..7962f4f2 100644 --- a/src/context/JamWalletInfoContextProvider.tsx +++ b/src/context/JamWalletInfoContextProvider.tsx @@ -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, diff --git a/src/hooks/useQueryOrderbook.test.ts b/src/hooks/useQueryOrderbook.test.ts new file mode 100644 index 00000000..3314324b --- /dev/null +++ b/src/hooks/useQueryOrderbook.test.ts @@ -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) + }) +}) diff --git a/src/hooks/useQueryOrderbook.ts b/src/hooks/useQueryOrderbook.ts new file mode 100644 index 00000000..1249d4f6 --- /dev/null +++ b/src/hooks/useQueryOrderbook.ts @@ -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 +} + +export function useQueryOrderbook( + options: Omit>[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, + } +} diff --git a/src/hooks/useQueryWalletHistory.test.ts b/src/hooks/useQueryWalletHistory.test.ts new file mode 100644 index 00000000..440e5fa8 --- /dev/null +++ b/src/hooks/useQueryWalletHistory.test.ts @@ -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) + }) +}) diff --git a/src/hooks/useQueryWalletHistory.ts b/src/hooks/useQueryWalletHistory.ts new file mode 100644 index 00000000..978030cb --- /dev/null +++ b/src/hooks/useQueryWalletHistory.ts @@ -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 +} + +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, + } +} diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 40b2d072..b5bb57a2 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -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 or <3>unfreeze an eligible UTXO 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.", + "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" } } diff --git a/src/lib/api/orderbook.ts b/src/lib/api/orderbook.ts index 5fe18b8d..388f6108 100644 --- a/src/lib/api/orderbook.ts +++ b/src/lib/api/orderbook.ts @@ -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 { diff --git a/src/lib/fidelityBondUtils.test.ts b/src/lib/fidelityBondUtils.test.ts index 47bde1df..d90452be 100644 --- a/src/lib/fidelityBondUtils.test.ts +++ b/src/lib/fidelityBondUtils.test.ts @@ -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) diff --git a/src/lib/fidelityBondUtils.ts b/src/lib/fidelityBondUtils.ts index ad38fd6e..12452b19 100644 --- a/src/lib/fidelityBondUtils.ts +++ b/src/lib/fidelityBondUtils.ts @@ -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, + } })() diff --git a/src/lib/formValidation.test.ts b/src/lib/formValidation.test.ts index 6957b190..1c87f48f 100644 --- a/src/lib/formValidation.test.ts +++ b/src/lib/formValidation.test.ts @@ -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) + }) +}) diff --git a/src/lib/formValidation.ts b/src/lib/formValidation.ts index 591e68f1..fee03563 100644 --- a/src/lib/formValidation.ts +++ b/src/lib/formValidation.ts @@ -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 = 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 = 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 diff --git a/src/lib/utils.test.ts b/src/lib/utils.test.ts index 63e14d6a..f63bcad5 100644 --- a/src/lib/utils.test.ts +++ b/src/lib/utils.test.ts @@ -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', () => { diff --git a/src/main.tsx b/src/main.tsx index e1161d82..b69cfcd0 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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( diff --git a/src/store/jamSettingsStore.ts b/src/store/jamSettingsStore.ts index 1ba6fd65..3363386d 100644 --- a/src/store/jamSettingsStore.ts +++ b/src/store/jamSettingsStore.ts @@ -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) +} diff --git a/src/stories/jam/ActiveCollaborativeSendAlert.stories.tsx b/src/stories/jam/ActiveCollaborativeSendAlert.stories.tsx new file mode 100644 index 00000000..4299292f --- /dev/null +++ b/src/stories/jam/ActiveCollaborativeSendAlert.stories.tsx @@ -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 = { + title: 'Jam/ActiveCollaborativeSendAlert', + component: ActiveCollaborativeSendAlert, + tags: ['autodocs'], + args: { + jars: [], + isAborting: false, + onAbort: () => alert('onAbort clicked'), + }, +} +export default meta + +type Story = StoryObj + +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, + }, +} diff --git a/src/stories/jam/OfferCard.stories.tsx b/src/stories/jam/OfferCard.stories.tsx new file mode 100644 index 00000000..b89f48c5 --- /dev/null +++ b/src/stories/jam/OfferCard.stories.tsx @@ -0,0 +1,151 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { OfferCard } from '@/components/earn/OfferCard' + +const meta: Meta = { + title: 'Jam/OfferCard', + component: OfferCard, + tags: ['autodocs'], +} +export default meta + +type Story = StoryObj + +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, + }, + }, +} diff --git a/src/stories/jam/OrderbookEmptyAlert.stories.tsx b/src/stories/jam/OrderbookEmptyAlert.stories.tsx new file mode 100644 index 00000000..c7a39a0f --- /dev/null +++ b/src/stories/jam/OrderbookEmptyAlert.stories.tsx @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { OrderbookEmptyAlert } from '@/components/ui/jam/OrderbookEmptyAlert' + +const meta: Meta = { + title: 'Jam/OrderbookEmptyAlert', + component: OrderbookEmptyAlert, + tags: ['autodocs'], +} +export default meta + +type Story = StoryObj + +export const Default: Story = { + args: { + isChecking: false, + onCheckClick: async () => alert('onCheck clicked'), + }, +} + +export const Checking: Story = { + args: { + isChecking: true, + onCheckClick: async () => alert('onCheck clicked'), + }, +} diff --git a/src/stories/jam/PreconditionAlert.stories.tsx b/src/stories/jam/PreconditionAlert.stories.tsx new file mode 100644 index 00000000..45b2d11c --- /dev/null +++ b/src/stories/jam/PreconditionAlert.stories.tsx @@ -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 => + ({ + 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 = { + title: 'Jam/PreconditionAlert', + component: PreconditionAlert, + tags: ['autodocs'], + args: { + summary: DEFAULT_SUMMARY, + i18nPrefix: 'send.coinjoin_precondition', + }, +} +export default meta + +type Story = StoryObj + +// --- 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 }), + ], + }, + }, +} diff --git a/src/stories/jam/ScheduleEntryItem.stories.tsx b/src/stories/jam/ScheduleEntryItem.stories.tsx index da69cace..e303631f 100644 --- a/src/stories/jam/ScheduleEntryItem.stories.tsx +++ b/src/stories/jam/ScheduleEntryItem.stories.tsx @@ -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 = { title: 'Jam/ScheduleEntryItem', @@ -12,6 +13,22 @@ export default meta type Story = StoryObj +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, []), - }, -} diff --git a/src/stories/jam/SendCoinJoinPreconditionAlert.stories.tsx b/src/stories/jam/SendCoinJoinPreconditionAlert.stories.tsx deleted file mode 100644 index cca58e5c..00000000 --- a/src/stories/jam/SendCoinJoinPreconditionAlert.stories.tsx +++ /dev/null @@ -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 => - ({ - 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 = { - title: 'Jam/SendCoinjoinPreconditionAlert', - component: SendCoinjoinPreconditionAlert, - tags: ['autodocs'], - args: { - summary: DEFAULT_SUMMARY, - }, -} -export default meta - -type Story = StoryObj - -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, - }), - ], - }, - }, -} diff --git a/src/stories/jam/SweepPreconditionAlert.stories.tsx b/src/stories/jam/SweepPreconditionAlert.stories.tsx deleted file mode 100644 index 9e8cbef5..00000000 --- a/src/stories/jam/SweepPreconditionAlert.stories.tsx +++ /dev/null @@ -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 => - ({ - 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 = { - title: 'Jam/SweepPreconditionAlert', - component: SweepPreconditionAlert, - tags: ['autodocs'], - args: { - summary: DEFAULT_SUMMARY, - }, -} -export default meta - -type Story = StoryObj - -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, - }), - ], - }, - }, -} diff --git a/src/test/withRuntimeLocale.ts b/src/test/withRuntimeLocale.ts new file mode 100644 index 00000000..d8d41354 --- /dev/null +++ b/src/test/withRuntimeLocale.ts @@ -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): Promise +export function withRuntimeLocale(locale: string, callback: () => void): void +export function withRuntimeLocale(locale: string, callback: () => void | Promise): void | Promise { + /* 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 + try { + result = callback() + } catch (error) { + restore() + throw error + } + + if (result instanceof Promise) { + return result.finally(restore) + } + + restore() + return undefined +} diff --git a/vite.config.ts b/vite.config.ts index 0dad02fe..56362412 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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,