diff --git a/.github/workflows/npm_tests.yml b/.github/workflows/npm_tests.yml index 585be75..f78fc21 100644 --- a/.github/workflows/npm_tests.yml +++ b/.github/workflows/npm_tests.yml @@ -15,12 +15,12 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: ref: ${{ github.event.pull_request.head.sha }} - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: node-version: '22' cache: 'npm' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7d8877a..08fcf85 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,12 +27,12 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: node-version: '22' cache: 'npm' @@ -108,7 +108,7 @@ jobs: run: echo "changelog=Initial release" >> $GITHUB_OUTPUT - name: Create GitHub Release - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v2 with: tag_name: v${{ steps.package-version.outputs.version }} name: Release v${{ steps.package-version.outputs.version }} diff --git a/CHANGELOG.md b/CHANGELOG.md index b809598..13bdfcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,29 @@ All notable changes to Q-Wallets will be documented in this file. -## [1.3.3] +## [1.3.4] - 2026-06-13 + +### Changed + +- Redesigned the wallet hub as a full visual and UX overhaul, replacing the older boxed layout with a cinematic coin-led workspace, atmospheric backgrounds, refreshed wallet tabs, and a cleaner right-side Address Book panel. +- Unified the QORT, BTC, LTC, DOGE, DGB, RVN, and ARRR wallet pages around the same shared workspace structure. +- Reworked send dialogs so transfers are easier to understand and harder to misread, with recipient-first layouts, cleaner amount fields, better Max/fee placement, clearer loading states while recipient data is being confirmed, and a smoother send-to-contact flow. +- Modernized the Address Book into a more usable wallet companion, with easier add/edit/delete flows, QORT name search when creating contacts, duplicate-contact warnings, explicit address confirmation before saving, softer row styling, lighter table text, pastel avatars, improved search styling, and better sync feedback. +- Refined transaction tables with lighter headers, improved pagination options, exact dates after 24 hours, smoother row hover feedback, and fewer layout jumps. +- Stabilized Receive QR layout inside Qortal Hub so the right rail can open naturally without making the background atmosphere shift. +- Reduced unnecessary automatic QORT balance refreshes from every 1 minute to every 2.5 minutes while keeping manual refresh available. + +### Added + +- Receive QR dialogs now work consistently for every wallet, not only QORT. +- QORT Address Book name search now marks results that are already saved with an "already in list" label. +- Save protection now warns users when they try to add a contact before confirming the address. +- Address Book sync now recognizes when local contacts are returned to the last synced state. +- Before and after screenshots for the redesign preview. Click either thumbnail to enlarge. + +[![Q-Wallets before redesign](/changelog/q-wallets-before-redesign.png)](/changelog/q-wallets-before-redesign.png) [![Q-Wallets after redesign](/changelog/q-wallets-after-redesign-1-3-3.png)](/changelog/q-wallets-after-redesign-1-3-3.png) + +## [1.3.3] - 2026-06-06 ### Added diff --git a/eslint.config.js b/eslint.config.js index f00cc47..ef9c549 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,5 +1,4 @@ import js from '@eslint/js'; -import globals from 'globals'; import reactHooks from 'eslint-plugin-react-hooks'; import reactRefresh from 'eslint-plugin-react-refresh'; import tseslint from 'typescript-eslint'; @@ -8,16 +7,12 @@ import prettierConfig from 'eslint-config-prettier'; export default tseslint.config( { ignores: ['dist'] }, + js.configs.recommended, + ...tseslint.configs.recommended, { - extends: [ - js.configs.recommended, - ...tseslint.configs.recommended, - 'plugin:prettier/recommended', - ], files: ['**/*.{ts,tsx}'], languageOptions: { ecmaVersion: 2020, - globals: globals.browser, }, plugins: { 'react-hooks': reactHooks, @@ -30,12 +25,8 @@ export default tseslint.config( 'warn', { allowConstantExport: true }, ], - 'prettier/prettier': 'error', + 'prettier/prettier': ['error', { endOfLine: 'auto' }], }, }, - { - // This disables ESLint rules that would conflict with Prettier - name: 'prettier-config', - rules: prettierConfig.rules, - } + prettierConfig ); diff --git a/package-lock.json b/package-lock.json index b8aeebe..3bea5b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "q-wallets", - "version": "1.3.3", + "version": "1.3.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "q-wallets", - "version": "1.3.3", + "version": "1.3.4", "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", @@ -18,7 +18,7 @@ "@mui/material": "^7.3.1", "@toolpad/core": "^0.16.0", "jotai": "^2.13.1", - "qapp-core": "^1.0.75", + "qapp-core": "^1.0.79", "react": "^19.0.0", "react-dom": "^19.0.0", "react-i18next": "^15.5.1", @@ -31,6 +31,7 @@ "react-window": "^1.8.11" }, "devDependencies": { + "@eslint/js": "^9.34.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.1", "@testing-library/user-event": "^14.6.1", @@ -40,12 +41,16 @@ "@vitejs/plugin-react": "^4.3.4", "@vitest/coverage-v8": "^4.0.16", "@vitest/ui": "^4.0.16", + "eslint": "^9.34.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.4", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", "happy-dom": "^20.0.11", "jsdom": "^27.4.0", "prettier": "^3.6.2", "typescript": "^5.8.2", + "typescript-eslint": "^8.40.0", "vite": "^6.2.2", "vitest": "^4.0.16" } @@ -399,6 +404,15 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/traverse/node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/@babel/types": { "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", @@ -1129,12 +1143,11 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", - "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "eslint-visitor-keys": "^3.4.3" }, @@ -1154,7 +1167,6 @@ "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, @@ -1163,27 +1175,25 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, "node_modules/@eslint/config-array": { - "version": "0.21.0", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", - "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { - "@eslint/object-schema": "^2.1.6", + "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", - "minimatch": "^3.1.2" + "minimatch": "^3.1.5" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1195,7 +1205,6 @@ "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } @@ -1206,7 +1215,6 @@ "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@types/json-schema": "^7.0.15" }, @@ -1215,21 +1223,20 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "ajv": "^6.12.4", + "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" }, "engines": { @@ -1245,7 +1252,6 @@ "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -1259,7 +1265,6 @@ "integrity": "sha512-EoyvqQnBNsV1CWaEJ559rxXL4c8V92gxirbawSmVUOWXlsRxxQXl6LmCpdUblgxgSkDIqKnhzba2SjRTI/A5Rw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -1268,12 +1273,11 @@ } }, "node_modules/@eslint/object-schema": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", - "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } @@ -1284,7 +1288,6 @@ "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@eslint/core": "^0.15.2", "levn": "^0.4.1" @@ -1344,7 +1347,6 @@ "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=18.18.0" } @@ -1355,7 +1357,6 @@ "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.3.0" @@ -1370,7 +1371,6 @@ "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=18.18" }, @@ -1385,7 +1385,6 @@ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=12.22" }, @@ -1400,7 +1399,6 @@ "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=18.18" }, @@ -1882,6 +1880,44 @@ "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/@pkgr/core": { "version": "0.2.9", "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", @@ -2540,8 +2576,7 @@ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/mdast": { "version": "4.0.4", @@ -2628,6 +2663,277 @@ "dev": true, "license": "MIT" }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.40.0.tgz", + "integrity": "sha512-w/EboPlBwnmOBtRbiOvzjD+wdiZdgFeo17lkltrtn7X37vagKKWJABvyfsJXTlHe6XBzugmYgd4A4nW+k8Mixw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.40.0", + "@typescript-eslint/type-utils": "8.40.0", + "@typescript-eslint/utils": "8.40.0", + "@typescript-eslint/visitor-keys": "8.40.0", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.40.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.40.0.tgz", + "integrity": "sha512-jCNyAuXx8dr5KJMkecGmZ8KI61KBUhkCob+SD+C+I5+Y1FWI2Y3QmY4/cxMCC5WAsZqoEtEETVhUiUMIGCf6Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.40.0", + "@typescript-eslint/types": "8.40.0", + "@typescript-eslint/typescript-estree": "8.40.0", + "@typescript-eslint/visitor-keys": "8.40.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.40.0.tgz", + "integrity": "sha512-/A89vz7Wf5DEXsGVvcGdYKbVM9F7DyFXj52lNYUDS1L9yJfqjW/fIp5PgMuEJL/KeqVTe2QSbXAGUZljDUpArw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.40.0", + "@typescript-eslint/types": "^8.40.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.40.0.tgz", + "integrity": "sha512-y9ObStCcdCiZKzwqsE8CcpyuVMwRouJbbSrNuThDpv16dFAj429IkM6LNb1dZ2m7hK5fHyzNcErZf7CEeKXR4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.40.0", + "@typescript-eslint/visitor-keys": "8.40.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.40.0.tgz", + "integrity": "sha512-jtMytmUaG9d/9kqSl/W3E3xaWESo4hFDxAIHGVW/WKKtQhesnRIJSAJO6XckluuJ6KDB5woD1EiqknriCtAmcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.40.0.tgz", + "integrity": "sha512-eE60cK4KzAc6ZrzlJnflXdrMqOBaugeukWICO2rB0KNvwdIMaEaYiywwHMzA1qFpTxrLhN9Lp4E/00EgWcD3Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.40.0", + "@typescript-eslint/typescript-estree": "8.40.0", + "@typescript-eslint/utils": "8.40.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.40.0.tgz", + "integrity": "sha512-ETdbFlgbAmXHyFPwqUIYrfc12ArvpBhEVgGAxVYSwli26dn8Ko+lIo4Su9vI9ykTZdJn+vJprs/0eZU0YMAEQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.40.0.tgz", + "integrity": "sha512-k1z9+GJReVVOkc1WfVKs1vBrR5MIKKbdAjDTPvIK3L8De6KbFfPFt6BKpdkdk7rZS2GtC/m6yI5MYX+UsuvVYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.40.0", + "@typescript-eslint/tsconfig-utils": "8.40.0", + "@typescript-eslint/types": "8.40.0", + "@typescript-eslint/visitor-keys": "8.40.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.40.0.tgz", + "integrity": "sha512-Cgzi2MXSZyAUOY+BFwGs17s7ad/7L+gKt6Y8rAVVWS+7o6wrjeFN4nVfTpbE25MNcxyJ+iYUXflbs2xR9h4UBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.40.0", + "@typescript-eslint/types": "8.40.0", + "@typescript-eslint/typescript-estree": "8.40.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.40.0.tgz", + "integrity": "sha512-8CZ47QwalyRjsypfwnbI3hKy5gJDPmrkLjkgMxhi0+DZZ2QNx2naS6/hWoVYUHU7LU2zleF68V9miaVZvhFfTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.40.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", @@ -2877,12 +3183,11 @@ } }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2896,7 +3201,6 @@ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } @@ -2930,12 +3234,11 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -2984,8 +3287,7 @@ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true, - "license": "Python-2.0", - "peer": true + "license": "Python-2.0" }, "node_modules/aria-query": { "version": "5.3.0", @@ -3065,8 +3367,7 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/base64-js": { "version": "1.5.1", @@ -3105,17 +3406,29 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/browserslist": { "version": "4.24.4", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz", @@ -3350,8 +3663,7 @@ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/convert-source-map": { "version": "1.9.0", @@ -3497,9 +3809,9 @@ "license": "MIT" }, "node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -3538,8 +3850,7 @@ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dequal": { "version": "2.0.3", @@ -3706,7 +4017,6 @@ "integrity": "sha512-RNCHRX5EwdrESy3Jc9o8ie8Bog+PeYvvSR8sDGoZxNFTvZ4dlxUB3WzQ3bQMztFrSRODGrLLj8g6OFuGY/aiQg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -3809,13 +4119,35 @@ } } }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.20", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.20.tgz", + "integrity": "sha512-XpbHQ2q5gUF8BGOX4dHe+71qoirYMhApEPZ7sfhF/dNnOF1UXnCMGZf79SFTBO7Bz5YEIT4TMieSlJBWhP9WBA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, "node_modules/eslint-scope": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" @@ -3833,7 +4165,6 @@ "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -3847,7 +4178,6 @@ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -3865,7 +4195,6 @@ "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", @@ -3879,12 +4208,11 @@ } }, "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "estraverse": "^5.1.0" }, @@ -3898,7 +4226,6 @@ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "estraverse": "^5.2.0" }, @@ -3912,7 +4239,6 @@ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=4.0" } @@ -3943,7 +4269,6 @@ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -3992,8 +4317,7 @@ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/fast-diff": { "version": "1.3.0", @@ -4002,6 +4326,36 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fast-json-patch": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/fast-json-patch/-/fast-json-patch-3.1.1.tgz", @@ -4013,16 +4367,24 @@ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } }, "node_modules/fdir": { "version": "6.5.0", @@ -4055,7 +4417,6 @@ "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "flat-cache": "^4.0.0" }, @@ -4075,6 +4436,19 @@ "node": ">= 12" } }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/find-root": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", @@ -4087,7 +4461,6 @@ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" @@ -4105,7 +4478,6 @@ "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" @@ -4173,7 +4545,6 @@ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "is-glob": "^4.0.3" }, @@ -4191,15 +4562,6 @@ "process": "^0.11.10" } }, - "node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/goober": { "version": "2.1.18", "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz", @@ -4209,6 +4571,13 @@ "csstype": "^3.0.10" } }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, "node_modules/happy-dom": { "version": "20.0.11", "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.0.11.tgz", @@ -4456,7 +4825,6 @@ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 4" } @@ -4483,7 +4851,6 @@ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.8.19" } @@ -4601,7 +4968,6 @@ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -4618,7 +4984,6 @@ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "is-extglob": "^2.1.1" }, @@ -4654,6 +5019,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/is-plain-obj": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", @@ -4825,7 +5200,6 @@ "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "argparse": "^2.0.1" }, @@ -4900,8 +5274,7 @@ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", @@ -4914,16 +5287,14 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/json5": { "version": "2.2.3", @@ -4944,7 +5315,6 @@ "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "json-buffer": "3.0.1" } @@ -4955,7 +5325,6 @@ "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" @@ -4976,7 +5345,6 @@ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "p-locate": "^5.0.0" }, @@ -4992,8 +5360,7 @@ "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/longest-streak": { "version": "3.1.0", @@ -5272,6 +5639,16 @@ "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", "license": "MIT" }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/micromark": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", @@ -5714,6 +6091,33 @@ ], "license": "MIT" }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/mimic-fn": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", @@ -5746,12 +6150,11 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -5831,8 +6234,7 @@ "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/node-releases": { "version": "2.0.19", @@ -5937,7 +6339,6 @@ "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", @@ -5956,7 +6357,6 @@ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "yocto-queue": "^0.1.0" }, @@ -5973,7 +6373,6 @@ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "p-limit": "^3.0.2" }, @@ -6058,7 +6457,6 @@ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -6166,7 +6564,6 @@ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8.0" } @@ -6285,9 +6682,9 @@ } }, "node_modules/qapp-core": { - "version": "1.0.75", - "resolved": "https://registry.npmjs.org/qapp-core/-/qapp-core-1.0.75.tgz", - "integrity": "sha512-IqJBtDgdUETtLDgsuGw9cjqDVpwRtAxFGsv0hYlFnDrK1ysfaAqhDA0TsZyIEphq47aVK6d5WpbvLsZsK6bQug==", + "version": "1.0.79", + "resolved": "https://registry.npmjs.org/qapp-core/-/qapp-core-1.0.79.tgz", + "integrity": "sha512-UJi4xAmHB6z63mKtydggZbm7XV9HoWKUWBZ/QSt75clcvJRWsODvMtlq1kHuL19es+t6y5AzaMjOju/lXJpj2g==", "license": "MIT", "dependencies": { "@tanstack/react-virtual": "^3.13.2", @@ -6329,6 +6726,27 @@ "integrity": "sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ==", "license": "MIT" }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/re-resizable": { "version": "6.11.2", "resolved": "https://registry.npmjs.org/re-resizable/-/re-resizable-6.11.2.tgz", @@ -6727,6 +7145,17 @@ "node": ">=4" } }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, "node_modules/rollup": { "version": "4.53.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.2.tgz", @@ -6769,6 +7198,30 @@ "fsevents": "~2.3.2" } }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -6963,7 +7416,6 @@ "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" }, @@ -7132,6 +7584,19 @@ "dev": true, "license": "MIT" }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", @@ -7188,6 +7653,19 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/ts-key-enum": { "version": "3.0.13", "resolved": "https://registry.npmjs.org/ts-key-enum/-/ts-key-enum-3.0.13.tgz", @@ -7206,7 +7684,6 @@ "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "prelude-ls": "^1.2.1" }, @@ -7228,6 +7705,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.40.0.tgz", + "integrity": "sha512-Xvd2l+ZmFDPEt4oj1QEXzA4A2uUK6opvKu3eGN9aGjB8au02lIVcLyi375w94hHyejTOmzIU77L8ol2sRg9n7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.40.0", + "@typescript-eslint/parser": "8.40.0", + "@typescript-eslint/typescript-estree": "8.40.0", + "@typescript-eslint/utils": "8.40.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, "node_modules/undici-types": { "version": "7.10.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", @@ -7359,7 +7860,6 @@ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "punycode": "^2.1.0" } @@ -7699,7 +8199,6 @@ "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -7787,7 +8286,6 @@ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, diff --git a/package.json b/package.json index 2c652f2..af20e54 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { "name": "q-wallets", "private": true, - "version": "1.3.3", + "version": "1.3.4", "type": "module", "scripts": { - "build": "tsc -b && vite build && cp CHANGELOG.md dist/", + "build": "tsc -b && vite build && node -e \"require('fs').copyFileSync('CHANGELOG.md', 'dist/CHANGELOG.md')\"", "dev": "vite", "format:check": "prettier --check .", "format": "prettier --write .", @@ -26,7 +26,7 @@ "@mui/material": "^7.3.1", "@toolpad/core": "^0.16.0", "jotai": "^2.13.1", - "qapp-core": "^1.0.75", + "qapp-core": "^1.0.79", "react": "^19.0.0", "react-dom": "^19.0.0", "react-i18next": "^15.5.1", @@ -39,6 +39,7 @@ "react-window": "^1.8.11" }, "devDependencies": { + "@eslint/js": "^9.34.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.1", "@testing-library/user-event": "^14.6.1", @@ -48,12 +49,16 @@ "@vitejs/plugin-react": "^4.3.4", "@vitest/coverage-v8": "^4.0.16", "@vitest/ui": "^4.0.16", + "eslint": "^9.34.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.4", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", "happy-dom": "^20.0.11", "jsdom": "^27.4.0", "prettier": "^3.6.2", "typescript": "^5.8.2", + "typescript-eslint": "^8.40.0", "vite": "^6.2.2", "vitest": "^4.0.16" } diff --git a/public/changelog/q-wallets-after-redesign-1-3-3.png b/public/changelog/q-wallets-after-redesign-1-3-3.png new file mode 100644 index 0000000..37aa9c3 Binary files /dev/null and b/public/changelog/q-wallets-after-redesign-1-3-3.png differ diff --git a/public/changelog/q-wallets-before-redesign.png b/public/changelog/q-wallets-before-redesign.png new file mode 100644 index 0000000..43ce9ee Binary files /dev/null and b/public/changelog/q-wallets-before-redesign.png differ diff --git a/src/AppLayout.tsx b/src/AppLayout.tsx index b7b6300..75ec9ed 100644 --- a/src/AppLayout.tsx +++ b/src/AppLayout.tsx @@ -1,6 +1,7 @@ import { AppBar, Box, + ButtonBase, Container, Dialog, DialogContent, @@ -19,7 +20,6 @@ import { useTheme, } from '@mui/material'; import type { Theme } from '@mui/material/styles'; -import CloseIcon from '@mui/icons-material/Close'; import { useEffect, useMemo, useContext, useState } from 'react'; import { Outlet, useLocation, useNavigate } from 'react-router-dom'; import walletContext, { IContextProps } from './contexts/walletContext'; @@ -31,14 +31,150 @@ import doge from './assets/doge.png'; import dgb from './assets/dgb.png'; import rvn from './assets/rvn.png'; import arrr from './assets/arrr.png'; +import qWalletsLogo from './assets/q-wallets-logo.webp'; import { useIframe } from './hooks/useIframeListener'; import { useTranslation } from 'react-i18next'; import packageJson from '../package.json'; import { EMPTY_STRING, TIME_MINUTES_1 } from './common/constants'; import MenuIcon from '@mui/icons-material/Menu'; import { syncAllAddressBooksOnStartup } from './utils/addressBookQDN'; +import { + ADDRESS_BOOK_STORAGE_EVENT, + setAddressBookAccountScope, +} from './utils/addressBookStorage'; import changelogContent from '../CHANGELOG.md?raw'; import Markdown from 'react-markdown'; +import CloseIcon from '@mui/icons-material/Close'; + +type SceneGlowLayerKey = 'primaryCyan' | 'topBlue' | 'stars' | 'vignette'; + +type SceneGlowLayerSettings = { + blur: number; + intensity: number; + spread: number; + x: number; + y: number; +}; + +type SceneGlowSettings = Record; + +const isChangelogAssetPath = (path?: string) => + typeof path === 'string' && + (path.startsWith('/changelog/') || path.startsWith('changelog/')); + +const getRuntimeBasePath = () => { + const basePath = window._qdnBase || import.meta.env.BASE_URL || ''; + + if (!basePath || basePath === '/') return ''; + return basePath.endsWith('/') ? basePath : `${basePath}/`; +}; + +const toChangelogAssetSrc = (path?: string) => { + if (!path) return ''; + if (!isChangelogAssetPath(path)) return path; + + return `${getRuntimeBasePath()}${path.replace(/^\/+/, '')}`; +}; + +const DEFAULT_SCENE_GLOW_SETTINGS: SceneGlowSettings = { + primaryCyan: { blur: 0, intensity: 99, spread: 81, x: 90, y: -61 }, + topBlue: { blur: 0, intensity: 100, spread: 100, x: 0, y: 0 }, + stars: { blur: 0, intensity: 96, spread: 85, x: 12, y: -11 }, + vignette: { blur: 0, intensity: 100, spread: 100, x: 0, y: 0 }, +}; + +const sceneLayerTransform = (layer: SceneGlowLayerSettings) => + `translate(${layer.x}px, ${layer.y}px) scale(${layer.spread / 100})`; + +const sceneGlowLayerSx = ( + layer: SceneGlowLayerSettings, + sx: { + background: string; + height: string; + left: string; + opacity: number; + top: string; + width: string; + } +) => ({ + background: sx.background, + filter: `blur(${layer.blur}px)`, + height: sx.height, + left: sx.left, + opacity: sx.opacity * (layer.intensity / 100), + pointerEvents: 'none', + position: 'absolute' as const, + top: sx.top, + transform: sceneLayerTransform(layer), + transformOrigin: 'center', + width: sx.width, +}); + +function SceneAtmosphere({ settings }: { settings: SceneGlowSettings }) { + return ( + ); } diff --git a/src/pages/rvn/index.tsx b/src/pages/rvn/index.tsx index 9e25a8d..21f90b7 100644 --- a/src/pages/rvn/index.tsx +++ b/src/pages/rvn/index.tsx @@ -1,59 +1,27 @@ import { ChangeEvent, - Key, MouseEvent, SyntheticEvent, useEffect, useState, } from 'react'; -import { - epochToAgo, - timeoutDelay, - cropString, - copyToClipboard, -} from '../../common/functions'; +import { timeoutDelay, copyToClipboard } from '../../common/functions'; import { useTheme } from '@mui/material/styles'; import { Alert, - AppBar, - Avatar, Box, - Button, - Dialog, DialogContent, - Grid, IconButton, - Paper, - Slider, - Table, - TableBody, - TableContainer, - TableFooter, - TableHead, - TablePagination, - TableRow, - TextField, - Toolbar, Typography, } from '@mui/material'; -import { NumericFormat as _NumericFormat } from 'react-number-format'; -const NumericFormat = _NumericFormat as React.FC & Record>; -import TableCell from '@mui/material/TableCell'; import Snackbar from '@mui/material/Snackbar'; type SnackbarCloseReason = 'timeout' | 'clickaway' | 'escapeKeyDown'; import CircularProgress from '@mui/material/CircularProgress'; -import LinearProgress from '@mui/material/LinearProgress'; -import QRCode from 'react-qr-code'; import { - Close, - CopyAllTwoTone, FirstPage, - ImportContacts, KeyboardArrowLeft, KeyboardArrowRight, LastPage, - Refresh, - Send, } from '@mui/icons-material'; import coinLogoRVN from '../../assets/rvn.png'; import { useTranslation } from 'react-i18next'; @@ -69,17 +37,24 @@ import { TIME_SECONDS_4, } from '../../common/constants'; import { - CustomWidthTooltip, SlideTransition, - StyledTableCell, - StyledTableRow, SubmitDialog, Transition, - WalletButtons, - WalletCard, + WalletSendDialog, } from '../../styles/page-styles'; import { Coin } from 'qapp-core'; import { AddressBookDialog } from '../../components/AddressBook/AddressBookDialog'; +import { + WalletExternalTransactionsList, + WalletTransactionsLoader, + WalletTransactionsCard, + WalletWorkspace, +} from '../../components/WalletWorkspace'; +import { + ExternalFeeSlider, + ExternalSendForm, + sendCoinDialogPaperSx, +} from '../../components/ExternalSendForm'; import { calculateMaxSendable } from '../../utils/maxSendable'; interface TablePaginationActionsProps { @@ -181,8 +156,6 @@ function valueTextRvn(value: number) { export default function RavencoinWallet() { const { t } = useTranslation(['core']); - const theme = useTheme(); - const [walletInfoRvn, setWalletInfoRvn] = useState({}); const [walletBalanceRvn, setWalletBalanceRvn] = useState(0); const [_isLoadingWalletInfoRvn, setIsLoadingWalletInfoRvn] = @@ -193,11 +166,13 @@ export default function RavencoinWallet() { const [isLoadingRvnTransactions, setIsLoadingRvnTransactions] = useState(true); const [page, setPage] = useState(0); - const [rowsPerPage, setRowsPerPage] = useState(25); + const [rowsPerPage, setRowsPerPage] = useState(10); const [copyRvnTxHash, setCopyRvnTxHash] = useState(EMPTY_STRING); const [openRvnSend, setOpenRvnSend] = useState(false); const [rvnAmount, setRvnAmount] = useState(0); const [rvnRecipient, setRvnRecipient] = useState(EMPTY_STRING); + const [rvnRecipientDisplayName, setRvnRecipientDisplayName] = + useState(EMPTY_STRING); const [addressFormatError, setAddressFormatError] = useState(false); const [rvnFee, setRvnFee] = useState(0); const [_walletInfoError, setWalletInfoError] = useState(null); @@ -209,6 +184,7 @@ export default function RavencoinWallet() { const [openSendRvnSuccess, setOpenSendRvnSuccess] = useState(false); const [openSendRvnError, setOpenSendRvnError] = useState(false); const [openRvnAddressBook, setOpenRvnAddressBook] = useState(false); + const [receivePanelOpen, setReceivePanelOpen] = useState(false); // Estimated fee in whole coins (fee rate applied to a ~1000-byte tx). const estimatedRvnFee = (rvnFee * 1000) / 1e8; @@ -222,11 +198,6 @@ export default function RavencoinWallet() { SEND_MAX_SAFETY_BUFFER_SATS ); - const emptyRows = - page > 0 - ? Math.max(0, (1 + page) * rowsPerPage - transactionsRvn.length) - : 0; - const handleOpenAddressBook = () => { setOpenRvnAddressBook(true); }; @@ -235,8 +206,9 @@ export default function RavencoinWallet() { setOpenRvnAddressBook(false); }; - const handleSelectAddress = (address: string, _name: string) => { + const handleSelectAddress = (address: string, name: string) => { setRvnRecipient(address); + setRvnRecipientDisplayName(name || EMPTY_STRING); setRvnAmount(0); setRvnFee(RVN_FEE); setOpenRvnAddressBook(false); @@ -250,6 +222,7 @@ export default function RavencoinWallet() { const handleOpenRvnSend = () => { setRvnAmount(0); setRvnRecipient(EMPTY_STRING); + setRvnRecipientDisplayName(EMPTY_STRING); setRvnFee(RVN_FEE); setOpenRvnSend(true); setAddressFormatError(false); @@ -271,6 +244,7 @@ export default function RavencoinWallet() { const pattern = /^(R[1-9A-HJ-NP-Za-km-z]{33})$/; setRvnRecipient(value); + setRvnRecipientDisplayName(EMPTY_STRING); if (pattern.test(value) || value === EMPTY_STRING) { setAddressFormatError(false); @@ -282,6 +256,7 @@ export default function RavencoinWallet() { const handleCloseRvnSend = () => { setRvnAmount(0); setRvnFee(0); + setRvnRecipientDisplayName(EMPTY_STRING); setOpenRvnSend(false); setAddressFormatError(false); setWalletInfoError(null); @@ -289,6 +264,15 @@ export default function RavencoinWallet() { setOpenSendRvnError(false); }; + const handleClearRvnRecipient = () => { + setRvnRecipient(EMPTY_STRING); + setRvnRecipientDisplayName(EMPTY_STRING); + setAddressFormatError(false); + setWalletInfoError(null); + setWalletBalanceError(null); + setOpenSendRvnError(false); + }; + const changeCopyRvnTxHash = async () => { setCopyRvnTxHash('Copied'); await timeoutDelay(TIME_SECONDS_2); @@ -467,6 +451,7 @@ export default function RavencoinWallet() { if (!sendRequest?.error) { setRvnAmount(0); setRvnRecipient(EMPTY_STRING); + setRvnRecipientDisplayName(EMPTY_STRING); setRvnFee(RVN_FEE); setOpenTxRvnSubmit(false); setOpenSendRvnSuccess(true); @@ -477,6 +462,7 @@ export default function RavencoinWallet() { } catch (error) { setRvnAmount(0); setRvnRecipient(EMPTY_STRING); + setRvnRecipientDisplayName(EMPTY_STRING); setRvnFee(RVN_FEE); setOpenTxRvnSubmit(false); setOpenSendRvnError(true); @@ -489,279 +475,86 @@ export default function RavencoinWallet() { const tableLoader = () => { return ( - - - - - - - {t('core:message.generic.loading_transactions', { - postProcess: 'capitalizeFirstChar', - })} - - - + ); }; - const transactionsTable = () => { - return ( - - - - - - {t('core:sender', { - postProcess: 'capitalizeFirstChar', - })} - - - {t('core:receiver', { - postProcess: 'capitalizeFirstChar', - })} - - - {t('core:transaction_hash', { - postProcess: 'capitalizeFirstChar', - })} - - - {t('core:total_amount', { - postProcess: 'capitalizeFirstChar', - })} - - - {t('core:fee.fee', { - postProcess: 'capitalizeFirstChar', - })} - - - {t('core:time', { - postProcess: 'capitalizeFirstChar', - })} - - - - - {(rowsPerPage > 0 - ? transactionsRvn.slice( - page * rowsPerPage, - page * rowsPerPage + rowsPerPage - ) - : transactionsRvn - ).map( - ( - row: { - inputs: { - address: any; - addressInWallet: boolean; - amount: number; - }[]; - outputs: { - address: any; - addressInWallet: boolean; - amount: number; - }[]; - txHash: string; - totalAmount: any; - feeAmount: any; - timestamp: number; - }, - k: Key - ) => ( - - - {row.inputs.map((input, index) => ( - - - {input.address} - - - {(Number(input.amount) / 1e8).toFixed( - DECIMAL_ROUND_UP - )} - - - ))} - - - {row.outputs.map((output, index) => ( - - - {output.address} - - - {(Number(output.amount) / 1e8).toFixed( - DECIMAL_ROUND_UP - )} - - - ))} - - - {cropString(row?.txHash)} - - { - copyToClipboard(row?.txHash); - changeCopyRvnTxHash(); - }} - > - - - - - - {row?.totalAmount > 0 ? ( - - + - {(Number(row?.totalAmount) / 1e8).toFixed( - DECIMAL_ROUND_UP - )} - - ) : ( - - {(Number(row?.totalAmount) / 1e8).toFixed( - DECIMAL_ROUND_UP - )} - - )} - - - {row?.totalAmount <= 0 ? ( - - - - {(Number(row?.feeAmount) / 1e8).toFixed( - DECIMAL_ROUND_UP - )} - - ) : ( - - - - {(Number(row?.feeAmount) / 1e8).toFixed( - DECIMAL_ROUND_UP - )} - - )} - - - - - {row?.timestamp - ? epochToAgo(row?.timestamp) - : t('core:message.generic.unconfirmed_transaction', { - postProcess: 'capitalizeFirstChar', - })} - - - - - ) - )} - {emptyRows > 0 && ( - - - - )} - - - - - - -
-
- ); - }; + const transactionsTable = () => ( + + t('core:action.copy_hash', { + hash, + postProcess: 'capitalizeFirstChar', + }), + fee: t('core:fee.fee', { + postProcess: 'capitalizeFirstChar', + }), + noTransactions: 'No transactions.', + receiver: t('core:receiver', { + postProcess: 'capitalizeFirstChar', + }), + rowsPerPage: t('core:rows_per_page', { + postProcess: 'capitalizeFirstChar', + }), + sender: t('core:sender', { + postProcess: 'capitalizeFirstChar', + }), + time: t('core:time', { + postProcess: 'capitalizeFirstChar', + }), + totalAmount: t('core:total_amount', { + postProcess: 'capitalizeFirstChar', + }), + transactionHash: t('core:transaction_hash', { + postProcess: 'capitalizeFirstChar', + }), + waitingConfirmation: t('core:message.generic.waiting_confirmation', { + postProcess: 'capitalizeFirstChar', + }), + }} + onCopyHash={(hash) => { + copyToClipboard(hash); + changeCopyRvnTxHash(); + }} + onPageChange={handleChangePage} + onRowsPerPageChange={handleChangeRowsPerPage} + page={page} + rows={transactionsRvn} + rowsPerPage={rowsPerPage} + /> + ); return ( - - + - - - - - - - - {t('core:action.transfer_coin', { - coin: Coin.RVN, - postProcess: 'capitalizeFirstChar', - })} - - - - - - - {t('core:balance_available', { - postProcess: 'capitalizeFirstChar', - })} - - - {isLoadingWalletBalanceRvn ? ( - - - - ) : walletBalanceError ? ( - walletBalanceError - ) : ( - walletBalanceRvn + ' RVN' - )} - - - - - {t('core:max_sendable', { - postProcess: 'capitalizeAll', - })} -    - - - {maxSendableRvnCoin() + ' RVN'} - - - - - - - } - valueIsNumericString - label="Amount (RVN)" - fullWidth - isAllowed={(values) => { - const maxRvnCoin = maxSendableRvnCoin(); - const { formattedValue, floatValue } = values; - return ( - formattedValue === EMPTY_STRING || - (floatValue ?? 0) <= maxRvnCoin - ); - }} - onValueChange={(values) => { - setRvnAmount(values.floatValue ?? 0); - }} - required - /> - - - - - - {t('core:message.generic.current_fee', { - fee: rvnFee, - postProcess: 'capitalizeFirstChar', - })} - - - - {t('core:message.generic.low_fee_transation', { - postProcess: 'capitalizeFirstChar', - })} - - - - + } + isBalanceLoading={isLoadingWalletBalanceRvn} + maxSendable={maxSendableRvnCoin()} + onAmountChange={setRvnAmount} + onClearRecipient={handleClearRvnRecipient} + onClose={handleCloseRvnSend} + onOpenAddressBook={handleOpenAddressBook} + onRecipientChange={handleRecipientChange} + onSend={sendRvnRequest} + onSendMax={handleSendMaxRvn} + recipient={rvnRecipient} + recipientDisplayName={rvnRecipientDisplayName} + recipientSubtitle={t('core:address_book_ui.symbol_contact', { + symbol: 'RVN', + })} + sendDisabled={disableCanSendRvn()} + showAddressBookButton + showBalanceMeter + symbol="RVN" + /> + - - - setReceivePanelOpen((prev) => !prev)} + receiveOpen={receivePanelOpen} + transactions={ + - - - - - {t('core:message.generic.ravencoin_wallet', { - postProcess: 'capitalizeFirstChar', - })} - - - - - - - {t('core:balance', { - postProcess: 'capitalizeFirstChar', - })} - - - {walletBalanceRvn ? ( - `${walletBalanceRvn} RVN` - ) : isLoadingWalletBalanceRvn ? ( - - ) : undefined} - - - - - - - {t('core:address', { - postProcess: 'capitalizeFirstChar', - })} - - - {walletInfoRvn?.address} - - - - copyToClipboard( - walletInfoRvn?.address ?? EMPTY_STRING - ) - } - > - - - - - - - - `1px solid ${t.palette.divider}`, - borderRadius: 1, - boxShadow: (t) => t.shadows[2], - display: 'flex', - height: '100%', - justifyContent: 'center', - maxHeight: { xs: 200, md: 150 }, - maxWidth: { xs: 200, md: 150 }, - p: 0.5, - }} - > - - - - - - - - - } - aria-label="Transfer" - onClick={handleOpenRvnSend} - > - {t('core:action.transfer_coin', { - coin: Coin.RVN, - postProcess: 'capitalizeFirstChar', - })} - - - } - aria-label="AddressBook" - onClick={handleOpenAddressBook} - > - {t('core:address_book', { - postProcess: 'capitalizeFirstChar', - })} - - - - - - - - - - - - {isLoadingRvnTransactions ? ( - {tableLoader()} - ) : ( - {transactionsTable()} - )} - - - - + {isLoadingRvnTransactions || loadingRefreshRvn ? ( + tableLoader() + ) : ( + {transactionsTable()} + )} + + } + /> ); } diff --git a/src/state/global/qort.ts b/src/state/global/qort.ts new file mode 100644 index 0000000..5d7218d --- /dev/null +++ b/src/state/global/qort.ts @@ -0,0 +1,6 @@ +import { atomWithStorage } from 'jotai/utils'; + +export const qortTransactionFiltersAtom = atomWithStorage( + 'q-wallets:qort:transaction-filters', + ['payments'] +); diff --git a/src/styles/page-styles.tsx b/src/styles/page-styles.tsx index 15d88ba..f16e9e0 100644 --- a/src/styles/page-styles.tsx +++ b/src/styles/page-styles.tsx @@ -13,30 +13,45 @@ import { import type { Theme } from '@mui/material/styles'; import { ComponentProps, forwardRef, Ref } from 'react'; +export const FAST_DIALOG_TRANSITION_MS = { + enter: 150, + exit: 90, +} as const; + export const Transition = forwardRef(function Transition( props: ComponentProps, ref: Ref ) { - return ; + const { timeout = FAST_DIALOG_TRANSITION_MS, ...slideProps } = props; + return ( + + ); }); export function SlideTransition(props: ComponentProps) { - return ; + const { timeout = FAST_DIALOG_TRANSITION_MS, ...slideProps } = props; + return ( + + ); } export const DialogGeneral = styled(Dialog)(({ theme }: { theme: Theme }) => ({ - '& .MuiDialogContent-root': { - padding: theme.spacing(2), - }, - '& .MuiDialogActions-root': { - padding: theme.spacing(1), - }, - '& .MuiDialog-paper': { - borderRadius: '15px', + '& .MuiBackdrop-root': { + backdropFilter: 'blur(8px)', + backgroundColor: + theme.palette.mode === 'dark' + ? 'rgba(0, 7, 12, 0.68)' + : 'rgba(15, 23, 42, 0.32)', }, -})); - -export const LightwalletDialog = styled(Dialog)(({ theme }: { theme: Theme }) => ({ '& .MuiDialogContent-root': { padding: theme.spacing(2), }, @@ -44,10 +59,84 @@ export const LightwalletDialog = styled(Dialog)(({ theme }: { theme: Theme }) => padding: theme.spacing(1), }, '& .MuiDialog-paper': { - borderRadius: '15px', + backgroundColor: + theme.palette.mode === 'dark' + ? 'rgba(4, 18, 31, 0.98)' + : theme.palette.background.paper, + border: + theme.palette.mode === 'dark' + ? `1px solid ${theme.palette.divider}` + : undefined, + borderRadius: 8, + boxShadow: + theme.palette.mode === 'dark' + ? '0 28px 72px rgba(0, 0, 0, 0.46)' + : undefined, }, })); +export const WalletSendDialog = styled(Dialog)( + ({ theme }: { theme: Theme }) => ({ + '& .MuiBackdrop-root': { + backdropFilter: 'blur(8px)', + backgroundColor: + theme.palette.mode === 'dark' + ? 'rgba(0, 7, 12, 0.68)' + : 'rgba(15, 23, 42, 0.32)', + }, + '& .MuiDialog-paper': { + backgroundColor: + theme.palette.mode === 'dark' ? 'rgba(4, 18, 31, 0.98)' : '#ffffff', + backgroundImage: + theme.palette.mode === 'dark' + ? 'radial-gradient(circle at 18% 0%, rgba(24, 189, 242, 0.1), transparent 36%), linear-gradient(180deg, rgba(7, 27, 42, 0.98) 0%, rgba(4, 13, 23, 0.99) 100%)' + : 'radial-gradient(circle at 13% 6%, rgba(11,143,211,0.12), transparent 30%), linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(246,251,253,0.99) 100%)', + border: `1px solid ${theme.palette.divider}`, + borderRadius: 8, + boxShadow: + theme.palette.mode === 'dark' + ? '0 28px 72px rgba(0, 0, 0, 0.46), inset 0 1px 0 rgba(255,255,255,0.04)' + : '0 24px 70px rgba(15,74,106,0.18), inset 0 1px 0 rgba(255,255,255,0.9)', + maxHeight: 'calc(100dvh - 48px)', + overflowX: 'hidden', + }, + '& .MuiAppBar-root': { + backgroundColor: 'transparent', + backgroundImage: 'none', + borderBottom: 'none', + boxShadow: 'none', + }, + '& .MuiToolbar-root': { + gap: theme.spacing(1), + minHeight: 56, + }, + '& .MuiButton-outlined': { + backgroundColor: 'rgba(116, 158, 180, 0.055)', + borderColor: 'rgba(116, 158, 180, 0.2)', + color: theme.palette.text.secondary, + '&:hover': { + backgroundColor: 'rgba(116, 158, 180, 0.1)', + borderColor: 'rgba(116, 158, 180, 0.36)', + color: theme.palette.text.primary, + }, + }, + }) +); + +export const LightwalletDialog = styled(Dialog)( + ({ theme }: { theme: Theme }) => ({ + '& .MuiDialogContent-root': { + padding: theme.spacing(2), + }, + '& .MuiDialogActions-root': { + padding: theme.spacing(1), + }, + '& .MuiDialog-paper': { + borderRadius: 8, + }, + }) +); + export const SubmitDialog = styled(Dialog)(({ theme }: { theme: Theme }) => ({ '& .MuiDialogContent-root': { padding: theme.spacing(2), @@ -56,7 +145,7 @@ export const SubmitDialog = styled(Dialog)(({ theme }: { theme: Theme }) => ({ padding: theme.spacing(1), }, '& .MuiDialog-paper': { - borderRadius: '15px', + borderRadius: 8, }, })); @@ -70,49 +159,91 @@ export const CustomWidthTooltip = styled( }, }); -export const WalletCard = styled(Card)({ +export const WalletCard = styled(Card)(({ theme }: { theme: Theme }) => ({ + backgroundColor: + theme.palette.mode === 'dark' ? '#0E2431' : 'rgba(255,255,255,0.96)', + backgroundImage: + theme.palette.mode === 'dark' + ? 'none' + : 'linear-gradient(180deg, rgba(255,255,255,0.96) 0%, rgba(248,250,252,0.98) 100%)', + border: `1px solid ${ + theme.palette.mode === 'dark' + ? 'rgba(116, 158, 180, 0.16)' + : 'rgba(17,24,39,0.08)' + }`, + borderRadius: 8, + boxShadow: + theme.palette.mode === 'dark' + ? 'inset 0 1px 0 rgba(255,255,255,0.025)' + : '0 14px 38px rgba(16, 24, 40, 0.06)', + margin: '0 auto', maxWidth: '100%', - margin: '20px, auto', - padding: '24px', - borderRadius: 16, - boxShadow: '0 4px 20px rgba(0, 0, 0, 0.1)', -}); +})); export const WalletButtons = styled(Button)(({ theme }: { theme: Theme }) => ({ + background: + theme.palette.mode === 'dark' + ? 'linear-gradient(180deg, #1baeed 0%, #0876d3 100%)' + : undefined, + borderRadius: 8, + boxShadow: + theme.palette.mode === 'dark' + ? '0 12px 28px rgba(5, 139, 211, 0.22)' + : undefined, + color: theme.palette.primary.contrastText, + minHeight: 42, + paddingInline: theme.spacing(2), width: 'auto', - backgroundColor: '#05a2e4', - color: 'white', - padding: 'auto', '&:hover': { - backgroundColor: '#02648d', + background: + theme.palette.mode === 'dark' + ? 'linear-gradient(180deg, #2ec4ff 0%, #0d86e2 100%)' + : undefined, + backgroundColor: theme.palette.primary.dark, }, [theme.breakpoints.down('sm')]: { width: '100%', }, })); -export const StyledTableCell = styled(TableCell)(({ theme }: { theme: Theme }) => ({ - [`&.${tableCellClasses.head}`]: { - backgroundColor: '#02648d', - color: theme.palette.common.white, - fontSize: 14, - whiteSpace: 'nowrap', - overflow: 'hidden', - textOverflow: 'ellipsis', - }, - [`&.${tableCellClasses.body}`]: { - fontSize: 13, - whiteSpace: 'nowrap', - overflow: 'hidden', - textOverflow: 'ellipsis', - }, -})); +export const StyledTableCell = styled(TableCell)( + ({ theme }: { theme: Theme }) => ({ + [`&.${tableCellClasses.head}`]: { + backgroundColor: + theme.palette.mode === 'dark' + ? 'rgba(255,255,255,0.025)' + : 'rgba(17,24,39,0.035)', + color: theme.palette.text.secondary, + fontSize: 12, + fontWeight: 700, + borderBottom: `1px solid ${theme.palette.divider}`, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + textTransform: 'uppercase', + }, + [`&.${tableCellClasses.body}`]: { + borderBottom: `1px solid ${theme.palette.divider}`, + fontSize: 13, + fontWeight: 500, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }, + }) +); -export const StyledTableRow = styled(TableRow)(({ theme }: { theme: Theme }) => ({ - '&:nth-of-type(odd)': { - backgroundColor: theme.palette.action.hover, - }, - '&:last-child td, &:last-child th': { - border: 0, - }, -})); +export const StyledTableRow = styled(TableRow)( + ({ theme }: { theme: Theme }) => ({ + backgroundColor: 'transparent', + '&:hover': { + backgroundColor: + theme.palette.mode === 'dark' + ? 'rgba(255,255,255,0.025)' + : 'rgba(17,24,39,0.025)', + }, + '&:last-child td, &:last-child th': { + border: 0, + }, + }) +); diff --git a/src/styles/theme/theme.ts b/src/styles/theme/theme.ts index d99a0ac..262bddf 100644 --- a/src/styles/theme/theme.ts +++ b/src/styles/theme/theme.ts @@ -31,14 +31,14 @@ const commonThemeOptions = { fontSize: '1rem', fontWeight: 400, lineHeight: 1.5, - letterSpacing: '0.5px', + letterSpacing: 0, }, body2: { fontSize: '0.875rem', fontWeight: 400, lineHeight: 1.4, - letterSpacing: '0.2px', + letterSpacing: 0, }, }, spacing: 8, @@ -56,27 +56,73 @@ const commonThemeOptions = { }, palette: { info: { - main: '#05a2e4', - hover: '#02648d', + main: '#1bb7f0', }, success: { - main: '#66bb6a', + main: '#27e18a', }, error: { - main: '#f44336', + main: '#ff5f66', }, }, - MuiDialog: { - styleOverrides: { - paper: { - backgroundImage: 'none', + components: { + MuiButton: { + defaultProps: { + disableElevation: true, + }, + styleOverrides: { + root: { + borderRadius: 8, + fontWeight: 600, + letterSpacing: 0, + textTransform: 'none' as const, + }, }, }, - }, - MuiPopover: { - styleOverrides: { - paper: { - backgroundImage: 'none', + MuiOutlinedInput: { + styleOverrides: { + root: { + borderRadius: 6, + }, + }, + }, + MuiCard: { + styleOverrides: { + root: { + backgroundImage: 'none', + }, + }, + }, + MuiDialog: { + styleOverrides: { + paper: { + backgroundImage: 'none', + borderRadius: 8, + }, + }, + }, + MuiDrawer: { + styleOverrides: { + paper: { + backgroundImage: 'none', + }, + }, + }, + MuiPopover: { + styleOverrides: { + paper: { + backgroundImage: 'none', + }, + }, + }, + MuiTab: { + styleOverrides: { + root: { + fontWeight: 600, + letterSpacing: 0, + minHeight: 44, + textTransform: 'none' as const, + }, }, }, }, @@ -87,20 +133,30 @@ const lightTheme = createTheme({ palette: { mode: 'light', primary: { - main: 'rgb(63, 81, 181)', - dark: 'rgb(113, 198, 212)', - light: 'rgb(180, 200, 235)', + main: '#0b8fd3', + dark: '#0871af', + light: '#d8f4ff', + contrastText: '#ffffff', }, secondary: { - main: 'rgba(194, 222, 236, 1)', + main: '#f2b84b', + }, + info: { + main: '#0b8fd3', + }, + success: { + main: '#2f9e44', + }, + error: { + main: '#dc2626', }, background: { - default: 'rgba(250, 250, 250, 1)', - paper: 'rgb(220, 220, 220)', // darker card background + default: '#f5f7f8', + paper: '#ffffff', }, text: { - primary: 'rgba(0, 0, 0, 0.87)', // 87% black (slightly softened) - secondary: 'rgba(0, 0, 0, 0.6)', // 60% black + primary: '#111827', + secondary: '#667085', }, }, }); @@ -110,20 +166,30 @@ const darkTheme = createTheme({ palette: { mode: 'dark', primary: { - main: 'rgb(100, 155, 240)', - dark: 'rgb(45, 92, 201)', - light: 'rgb(130, 185, 255)', + main: '#18bdf2', + dark: '#0e82d8', + light: '#7adfff', + contrastText: '#ffffff', }, secondary: { - main: 'rgb(69, 173, 255)', + main: '#f4c76b', + }, + info: { + main: '#18bdf2', + }, + success: { + main: '#22e38a', + }, + error: { + main: '#ff5f66', }, background: { - default: 'rgb(49, 51, 56)', - paper: 'rgb(62, 64, 68)', + default: '#07141C', + paper: '#0e1622', }, text: { - primary: 'rgb(255, 255, 255)', - secondary: 'rgb(179, 179, 179)', + primary: '#f4f8fb', + secondary: '#9dafba', }, }, }); diff --git a/src/utils/Types.tsx b/src/utils/Types.tsx index caeb53f..3aa577a 100644 --- a/src/utils/Types.tsx +++ b/src/utils/Types.tsx @@ -68,4 +68,7 @@ export interface AddressBookEntry { coinType: Coin; // e.g., 'BTC', 'DOGE', 'LTC' createdAt: number; // Unix timestamp updatedAt?: number; // Unix timestamp (optional) + favorite?: boolean; + favoriteAt?: number; + sortOrder?: number; } diff --git a/src/utils/__tests__/addressBookQDN.test.ts b/src/utils/__tests__/addressBookQDN.test.ts index 98788a5..0edd78c 100644 --- a/src/utils/__tests__/addressBookQDN.test.ts +++ b/src/utils/__tests__/addressBookQDN.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { syncAllAddressBooksOnStartup } from '../addressBookQDN'; +import { publishToQDN, syncAllAddressBooksOnStartup } from '../addressBookQDN'; +import { + createAddressBookSyncSignature, + setAddressBookAccountScope, +} from '../addressBookStorage'; import type { AddressBookEntry } from '../Types'; // Override the global qapp-core mock (from setup.ts) to include the functions @@ -56,6 +60,9 @@ const ENTRY_DGB: AddressBookEntry = { // --------------------------------------------------------------------------- const STORAGE_KEY = 'q-wallets-addressbook-QORT'; +const SYNC_REQUIRED_KEY = 'q-wallets-addressbook-sync-required-QORT'; +const SYNC_BASELINE_KEY = 'q-wallets-addressbook-sync-baseline-QORT'; +const PUBLISHED_HASH_KEY = 'q-wallets-addressbook-published-QORT'; function setLocalStorage( entries: AddressBookEntry[], @@ -73,6 +80,8 @@ function getStoredData(coinType = 'QORT') { return raw ? JSON.parse(raw) : null; } +const isSyncRequired = () => localStorage.getItem(SYNC_REQUIRED_KEY) === 'true'; + // --------------------------------------------------------------------------- // Test suite // --------------------------------------------------------------------------- @@ -85,6 +94,7 @@ describe('syncAllAddressBooksOnStartup', () => { beforeEach(() => { localStorage.clear(); + setAddressBookAccountScope(null); vi.clearAllMocks(); qdnDataForQort = null; @@ -96,10 +106,7 @@ describe('syncAllAddressBooksOnStartup', () => { case 'FETCH_QDN_RESOURCE': // Return mock encrypted data only when QDN data has been set for QORT. - if ( - request.identifier === STORAGE_KEY && - qdnDataForQort !== null - ) { + if (request.identifier === STORAGE_KEY && qdnDataForQort !== null) { return 'mock-encrypted-data'; } // Simulate "resource not found" for every other coin / unset QORT. @@ -132,10 +139,27 @@ describe('syncAllAddressBooksOnStartup', () => { const wasQortPublished = () => mockQortalRequest.mock.calls.some( ([req]) => - req.action === 'PUBLISH_QDN_RESOURCE' && - req.identifier === STORAGE_KEY + req.action === 'PUBLISH_QDN_RESOURCE' && req.identifier === STORAGE_KEY ); + describe('publishToQDN', () => { + it('publishes only when explicitly called', async () => { + const publishedAt = await publishToQDN('QORT', [ENTRY_ALICE], 'TestUser'); + + expect(publishedAt).toEqual(expect.any(Number)); + expect(wasQortPublished()).toBe(true); + }); + + it('records the published hash and clears the sync-required marker', async () => { + localStorage.setItem(SYNC_REQUIRED_KEY, 'true'); + + await publishToQDN('QORT', [ENTRY_ALICE], 'TestUser'); + + expect(localStorage.getItem(PUBLISHED_HASH_KEY)).toBeTruthy(); + expect(isSyncRequired()).toBe(false); + }); + }); + // ------------------------------------------------------------------------- // BUG FIX: skip publish when timestamps diverge but content is unchanged // ------------------------------------------------------------------------- @@ -186,32 +210,47 @@ describe('syncAllAddressBooksOnStartup', () => { }); // ------------------------------------------------------------------------- - // BUG FIX: publish AND sync local timestamp when content genuinely differs + // BUG FIX: startup never publishes when content genuinely differs // ------------------------------------------------------------------------- - describe('BUG FIX — local timestamp newer than QDN, different content → publish', () => { - it('publishes when local has entries that are not present in QDN', async () => { + describe('BUG FIX — local timestamp newer than QDN, different content → manual sync required', () => { + it('does not publish when local has entries that are not present in QDN', async () => { setLocalStorage([ENTRY_BOB], 3000); qdnDataForQort = { entries: [ENTRY_ALICE], lastUpdated: 1000 }; await syncAllAddressBooksOnStartup('TestUser'); - expect(wasQortPublished()).toBe(true); + expect(wasQortPublished()).toBe(false); }); - it('updates the local timestamp after a startup-triggered publish', async () => { + it('keeps the newer local snapshot and marks sync-required state', async () => { setLocalStorage([ENTRY_BOB], 3000); + localStorage.setItem(SYNC_BASELINE_KEY, 'stale-clean-baseline'); qdnDataForQort = { entries: [ENTRY_ALICE], lastUpdated: 1000 }; - const before = Date.now(); await syncAllAddressBooksOnStartup('TestUser'); - const after = Date.now(); - // The local timestamp must be advanced to ~publishedAt so the next login - // sees equal timestamps and goes straight to the hash-comparison path. - const newTimestamp = getStoredData().lastUpdated; - expect(newTimestamp).toBeGreaterThanOrEqual(before); - expect(newTimestamp).toBeLessThanOrEqual(after); + const stored = getStoredData(); + expect(isSyncRequired()).toBe(true); + expect(localStorage.getItem(SYNC_BASELINE_KEY)).toBe( + createAddressBookSyncSignature([ENTRY_ALICE]) + ); + expect(stored.lastUpdated).toBe(3000); + expect(stored.entries[0].id).toBe(ENTRY_BOB.id); + }); + + it('keeps a just-published local snapshot while QDN is still propagating', async () => { + const publishedAt = await publishToQDN('QORT', [ENTRY_BOB], 'TestUser'); + setLocalStorage([ENTRY_BOB], publishedAt!); + qdnDataForQort = { entries: [ENTRY_ALICE], lastUpdated: 1000 }; + mockQortalRequest.mockClear(); + + await syncAllAddressBooksOnStartup('TestUser'); + + const stored = getStoredData(); + expect(wasQortPublished()).toBe(false); + expect(isSyncRequired()).toBe(false); + expect(stored.entries[0].id).toBe(ENTRY_BOB.id); }); }); @@ -288,13 +327,13 @@ describe('syncAllAddressBooksOnStartup', () => { // ------------------------------------------------------------------------- describe('No QDN data exists', () => { - it('publishes local entries so they are backed up to QDN', async () => { + it('does not publish local entries on startup', async () => { setLocalStorage([ENTRY_ALICE], 1000); // qdnDataForQort stays null → FETCH_QDN_RESOURCE throws 404. await syncAllAddressBooksOnStartup('TestUser'); - expect(wasQortPublished()).toBe(true); + expect(wasQortPublished()).toBe(false); }); it('does not publish when there are no local entries either', async () => { @@ -304,29 +343,21 @@ describe('syncAllAddressBooksOnStartup', () => { expect(wasQortPublished()).toBe(false); }); - it('aligns the local timestamp with QDN after publishing', async () => { - // Root cause 1 fix: the timestamp stored locally must equal the timestamp - // that was written into QDN so the next startup sees equal timestamps. + it('marks local entries as requiring manual sync', async () => { setLocalStorage([ENTRY_ALICE], 1000); - const before = Date.now(); await syncAllAddressBooksOnStartup('TestUser'); - const after = Date.now(); - const stored = getStoredData(); - expect(stored.lastUpdated).toBeGreaterThanOrEqual(before); - expect(stored.lastUpdated).toBeLessThanOrEqual(after); + expect(isSyncRequired()).toBe(true); + expect(getStoredData().lastUpdated).toBe(1000); }); - it('records the published hash after a successful publish', async () => { - // Root cause 2 fix: publishToQDN must write the published-hash sentinel - // so future startups can detect a propagation-delay scenario. + it('does not record a published hash from startup sync', async () => { setLocalStorage([ENTRY_ALICE], 1000); await syncAllAddressBooksOnStartup('TestUser'); - const hash = localStorage.getItem('q-wallets-addressbook-published-QORT'); - expect(hash).toBeTruthy(); + expect(localStorage.getItem(PUBLISHED_HASH_KEY)).toBeNull(); }); }); @@ -336,14 +367,15 @@ describe('syncAllAddressBooksOnStartup', () => { describe('Steady-state — QDN and local are already in sync', () => { it('does not publish when QDN carries the exact same entries and equal timestamp (hash field present)', async () => { - // Run a first startup to capture the real hash that publishToQDN generates. - setLocalStorage([ENTRY_ALICE], 1000); - await syncAllAddressBooksOnStartup('TestUser'); + // Run an explicit publish to capture the real hash that publishToQDN generates. + const alignedTimestamp = await publishToQDN( + 'QORT', + [ENTRY_ALICE], + 'TestUser' + ); + setLocalStorage([ENTRY_ALICE], alignedTimestamp!); - const publishedHash = localStorage.getItem( - 'q-wallets-addressbook-published-QORT' - )!; - const alignedTimestamp = getStoredData().lastUpdated; + const publishedHash = localStorage.getItem(PUBLISHED_HASH_KEY)!; // QDN now has the published data (propagation complete). qdnDataForQort = { @@ -360,13 +392,14 @@ describe('syncAllAddressBooksOnStartup', () => { }); it('does not modify localStorage when already in sync', async () => { - setLocalStorage([ENTRY_ALICE], 1000); - await syncAllAddressBooksOnStartup('TestUser'); + const alignedTimestamp = await publishToQDN( + 'QORT', + [ENTRY_ALICE], + 'TestUser' + ); + setLocalStorage([ENTRY_ALICE], alignedTimestamp!); - const publishedHash = localStorage.getItem( - 'q-wallets-addressbook-published-QORT' - )!; - const alignedTimestamp = getStoredData().lastUpdated; + const publishedHash = localStorage.getItem(PUBLISHED_HASH_KEY)!; qdnDataForQort = { entries: [ENTRY_ALICE], @@ -436,18 +469,18 @@ describe('syncAllAddressBooksOnStartup', () => { }); // ------------------------------------------------------------------------- - // BUG: optional `updatedAt` field causes hash divergence → spurious publish - // even though the user-visible content is unchanged. + // BUG FIX: optional `updatedAt` field must not cause hash divergence + // when the user-visible content is unchanged. // // Scenario: the user calls updateAddress (which stamps updatedAt on the // entry and advances localLastUpdated). QDN was published before updatedAt // existed, so the stored QDN hash was computed without that field. // At the next startup: localLastUpdated > qdnLastUpdated (Path B), hashes - // differ only because of updatedAt → the code publishes unnecessarily. + // differ only because of updatedAt → the code used to publish unnecessarily. // ------------------------------------------------------------------------- - describe('BUG — updatedAt field in local entry causes hash mismatch with QDN', () => { - it('publishes even though only the internal updatedAt field differs (bug: should NOT publish)', async () => { + describe('BUG FIX — updatedAt field in local entry does not affect QDN hash', () => { + it('does not publish or require sync when only the internal updatedAt field differs', async () => { // Simulate updateAddress having been called: entry now has updatedAt and // local timestamp is newer than QDN. const entryWithUpdatedAt = { ...ENTRY_ALICE, updatedAt: 99999 }; @@ -458,30 +491,27 @@ describe('syncAllAddressBooksOnStartup', () => { // We simulate that by computing what the QDN hash *would* have been // (we use the sentinel approach: publish the old entry once to capture // its real hash, then set QDN accordingly). - setLocalStorage([ENTRY_ALICE], 1000); - await syncAllAddressBooksOnStartup('TestUser'); // publishes ENTRY_ALICE - const qdnHash = localStorage.getItem( - 'q-wallets-addressbook-published-QORT' - )!; - const qdnTimestamp = getStoredData().lastUpdated; + const qdnTimestamp = await publishToQDN( + 'QORT', + [ENTRY_ALICE], + 'TestUser' + ); + const qdnHash = localStorage.getItem(PUBLISHED_HASH_KEY)!; mockQortalRequest.mockClear(); localStorage.clear(); // Now restore the "post-updateAddress" local state. - setLocalStorage([entryWithUpdatedAt], qdnTimestamp + 1000); // local > QDN + setLocalStorage([entryWithUpdatedAt], qdnTimestamp! + 1000); // local > QDN qdnDataForQort = { entries: [ENTRY_ALICE], - lastUpdated: qdnTimestamp, + lastUpdated: qdnTimestamp!, hash: qdnHash, }; await syncAllAddressBooksOnStartup('TestUser'); - // BUG: the code currently DOES publish because generateHash(localEntries) - // includes updatedAt while qdnHash was computed without it. - // Once the bug is fixed (e.g. by excluding updatedAt from the hash), - // change this expectation to toBe(false). - expect(wasQortPublished()).toBe(true); // documents current (buggy) behaviour + expect(wasQortPublished()).toBe(false); + expect(isSyncRequired()).toBe(false); }); }); @@ -492,9 +522,9 @@ describe('syncAllAddressBooksOnStartup', () => { describe('BUG FIX — QDN unavailable but content matches last publish → no publish', () => { it('does NOT publish on the second startup when content is unchanged', async () => { - // First startup: QDN is null, local has entries → publishes and records hash. - setLocalStorage([ENTRY_ALICE], 1000); - await syncAllAddressBooksOnStartup('TestUser'); + // An explicit sync records the hash before the propagation-delay check. + const publishedAt = await publishToQDN('QORT', [ENTRY_ALICE], 'TestUser'); + setLocalStorage([ENTRY_ALICE], publishedAt!); expect(wasQortPublished()).toBe(true); // Reset call recorder. @@ -504,12 +534,12 @@ describe('syncAllAddressBooksOnStartup', () => { await syncAllAddressBooksOnStartup('TestUser'); expect(wasQortPublished()).toBe(false); + expect(isSyncRequired()).toBe(false); }); - it('DOES publish when content has changed since the last publish', async () => { + it('marks sync required when content has changed since the last publish', async () => { // Simulate a prior publish of ENTRY_ALICE. - setLocalStorage([ENTRY_ALICE], 1000); - await syncAllAddressBooksOnStartup('TestUser'); + await publishToQDN('QORT', [ENTRY_ALICE], 'TestUser'); mockQortalRequest.mockClear(); // User adds ENTRY_BOB locally; QDN is still null. @@ -517,17 +547,19 @@ describe('syncAllAddressBooksOnStartup', () => { await syncAllAddressBooksOnStartup('TestUser'); - expect(wasQortPublished()).toBe(true); + expect(wasQortPublished()).toBe(false); + expect(isSyncRequired()).toBe(true); }); - it('publishes on first startup when the published-hash sentinel is absent', async () => { + it('marks sync required on first startup when the published-hash sentinel is absent', async () => { // No prior publish recorded. setLocalStorage([ENTRY_ALICE], 1000); // qdnDataForQort remains null. await syncAllAddressBooksOnStartup('TestUser'); - expect(wasQortPublished()).toBe(true); + expect(wasQortPublished()).toBe(false); + expect(isSyncRequired()).toBe(true); }); }); @@ -544,7 +576,11 @@ describe('syncAllAddressBooksOnStartup', () => { describe('BUG FIX — QDN returns resource with mismatched coinType → discard', () => { it('does not store entries from a mismatched top-level coinType into local storage', async () => { // QORT local storage is empty. QDN returns DGB data for the QORT identifier. - qdnDataForQort = { coinType: 'DGB', entries: [ENTRY_DGB], lastUpdated: 5000 }; + qdnDataForQort = { + coinType: 'DGB', + entries: [ENTRY_DGB], + lastUpdated: 5000, + }; await syncAllAddressBooksOnStartup('TestUser'); @@ -556,7 +592,11 @@ describe('syncAllAddressBooksOnStartup', () => { setLocalStorage([ENTRY_ALICE], 1000); // QDN returns DGB data with a newer timestamp — without the fix this // would overwrite ENTRY_ALICE with ENTRY_DGB under the QORT key. - qdnDataForQort = { coinType: 'DGB', entries: [ENTRY_DGB], lastUpdated: 5000 }; + qdnDataForQort = { + coinType: 'DGB', + entries: [ENTRY_DGB], + lastUpdated: 5000, + }; await syncAllAddressBooksOnStartup('TestUser'); diff --git a/src/utils/__tests__/addressBookStorage.test.ts b/src/utils/__tests__/addressBookStorage.test.ts index 2141576..e7785a8 100644 --- a/src/utils/__tests__/addressBookStorage.test.ts +++ b/src/utils/__tests__/addressBookStorage.test.ts @@ -2,21 +2,19 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { Coin } from 'qapp-core'; import { getAddressBook, + getAddressBookStorageKey, addAddress, updateAddress, deleteAddress, searchAddresses, + setAddressBookAccountScope, } from '../addressBookStorage'; import type { AddressBookEntry } from '../Types'; -// Mock the QDN module to prevent actual syncing during tests -vi.mock('../addressBookQDN', () => ({ - debouncedPublishToQDN: vi.fn(), -})); - describe('addressBookStorage', () => { beforeEach(() => { localStorage.clear(); + setAddressBookAccountScope(null); vi.clearAllMocks(); }); @@ -96,7 +94,10 @@ describe('addressBookStorage', () => { createdAt: Date.now(), }, ]; - localStorage.setItem('q-wallets-addressbook-BTC', JSON.stringify(oldFormatData)); + localStorage.setItem( + 'q-wallets-addressbook-BTC', + JSON.stringify(oldFormatData) + ); const result = getAddressBook(Coin.BTC); expect(result).toHaveLength(1); @@ -112,7 +113,7 @@ describe('addressBookStorage', () => { it('migration sets lastUpdated to 0 so QDN is always treated as newer', () => { // Root cause 3 fix: old-format data must migrate with lastUpdated:0 so // that any valid QDN timestamp (> 0) takes precedence on startup sync, - // avoiding a spurious "local is newer" publish proposal. + // avoiding a spurious "local is newer" manual sync prompt. const oldFormatData = [ { id: 'test-1', @@ -123,7 +124,10 @@ describe('addressBookStorage', () => { createdAt: 1000, }, ]; - localStorage.setItem('q-wallets-addressbook-BTC', JSON.stringify(oldFormatData)); + localStorage.setItem( + 'q-wallets-addressbook-BTC', + JSON.stringify(oldFormatData) + ); getAddressBook(Coin.BTC); @@ -131,6 +135,61 @@ describe('addressBookStorage', () => { const parsed = JSON.parse(stored!); expect(parsed.lastUpdated).toBe(0); }); + + it('keeps address books isolated by account scope', () => { + setAddressBookAccountScope('Qaccount111111111111111111111111111111'); + addAddress({ + name: 'Alice', + address: '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa', + note: '', + coinType: Coin.BTC, + }); + + setAddressBookAccountScope('Qaccount222222222222222222222222222222'); + expect(getAddressBook(Coin.BTC)).toEqual([]); + + addAddress({ + name: 'Bob', + address: '1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2', + note: '', + coinType: Coin.BTC, + }); + + expect(getAddressBook(Coin.BTC)[0].name).toBe('Bob'); + + setAddressBookAccountScope('Qaccount111111111111111111111111111111'); + expect(getAddressBook(Coin.BTC)[0].name).toBe('Alice'); + }); + + it('migrates legacy unscoped storage into the current account only once', () => { + const legacyData = { + entries: [ + { + id: 'test-1', + name: 'Alice', + address: '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa', + note: '', + coinType: Coin.BTC, + createdAt: 1000, + }, + ], + lastUpdated: 1000, + }; + localStorage.setItem( + 'q-wallets-addressbook-BTC', + JSON.stringify(legacyData) + ); + + setAddressBookAccountScope('Qaccount111111111111111111111111111111'); + expect(getAddressBook(Coin.BTC)).toHaveLength(1); + expect( + localStorage.getItem(getAddressBookStorageKey(Coin.BTC)) + ).not.toBeNull(); + expect(localStorage.getItem('q-wallets-addressbook-BTC')).toBeNull(); + + setAddressBookAccountScope('Qaccount222222222222222222222222222222'); + expect(getAddressBook(Coin.BTC)).toEqual([]); + }); }); describe('addAddress()', () => { diff --git a/src/utils/__tests__/invisibleCharacters.test.ts b/src/utils/__tests__/invisibleCharacters.test.ts new file mode 100644 index 0000000..2376d30 --- /dev/null +++ b/src/utils/__tests__/invisibleCharacters.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from 'vitest'; +import { hasInvisibleCharacters } from '../invisibleCharacters'; + +describe('hasInvisibleCharacters', () => { + it('returns false for plain ASCII text', () => { + expect(hasInvisibleCharacters('Alice')).toBe(false); + expect(hasInvisibleCharacters('bob_jones-123')).toBe(false); + }); + + it('returns false for an empty string', () => { + expect(hasInvisibleCharacters('')).toBe(false); + }); + + it('returns false for ordinary whitespace (space, tab, newline)', () => { + expect(hasInvisibleCharacters('Bob Jones')).toBe(false); + expect(hasInvisibleCharacters('line1\nline2\tend')).toBe(false); + }); + + it('returns false for visible non-ASCII / accented characters', () => { + expect(hasInvisibleCharacters('José')).toBe(false); // José + expect(hasInvisibleCharacters('日本語')).toBe(false); // 日本語 + }); + + it.each([ + ['soft hyphen U+00AD', 'al­ice'], + ['zero-width space U+200B', 'al​ice'], + ['zero-width non-joiner U+200C', 'al‌ice'], + ['left-to-right mark U+200E', 'al‎ice'], + ['right-to-left mark U+200F', 'al‏ice'], + ['byte order mark U+FEFF', 'alice'], + ['Hangul filler U+3164', 'alㅤice'], + ['braille blank U+2800', 'al⠀ice'], + ['Arabic letter mark U+061C', 'al؜ice'], + ])('detects %s', (_label, value) => { + expect(hasInvisibleCharacters(value)).toBe(true); + }); + + it('normalizes with NFKC before checking, so an ideographic space (U+3000) collapses to a plain space and is not flagged', () => { + expect(hasInvisibleCharacters('a b')).toBe(false); + }); +}); diff --git a/src/utils/__tests__/qortalNodeApi.test.ts b/src/utils/__tests__/qortalNodeApi.test.ts new file mode 100644 index 0000000..06ede65 --- /dev/null +++ b/src/utils/__tests__/qortalNodeApi.test.ts @@ -0,0 +1,170 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { searchQortalNames, getQortalNameData } from '../qortalNodeApi'; + +const okJson = (payload: unknown) => ({ + ok: true, + json: async () => payload, +}); + +describe('qortalNodeApi', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()); + // Silence the warnings the module logs on the failure paths under test. + vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + describe('searchQortalNames', () => { + it('returns an empty array without calling the API for a blank query', async () => { + const fetchMock = vi.mocked(fetch); + + expect(await searchQortalNames(' ')).toEqual([]); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('returns an empty array without calling the API when the signal is already aborted', async () => { + const fetchMock = vi.mocked(fetch); + + expect(await searchQortalNames('alice', 10, AbortSignal.abort())).toEqual( + [] + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('builds the search URL with trimmed query and limit, and returns parsed results', async () => { + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValue( + okJson([{ name: 'Alice', owner: 'QAlice' }]) as unknown as Response + ); + + const results = await searchQortalNames(' Alice ', 5); + + expect(results).toEqual([{ name: 'Alice', owner: 'QAlice' }]); + const calledUrl = fetchMock.mock.calls[0][0] as string; + expect(calledUrl).toBe('/names/search?limit=5&prefix=true&query=Alice'); + }); + + it('discards malformed entries (missing/invalid name or owner)', async () => { + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValue( + okJson([ + { name: 'Alice', owner: 'QAlice' }, + { name: 'NoOwner' }, + { owner: 'QNoName' }, + { name: 42, owner: 'QNumber' }, + null, + 'not-an-object', + ]) as unknown as Response + ); + + expect(await searchQortalNames('a')).toEqual([ + { name: 'Alice', owner: 'QAlice' }, + ]); + }); + + it('returns an empty array when the payload is not an array', async () => { + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValue( + okJson({ name: 'Alice', owner: 'QAlice' }) as unknown as Response + ); + + expect(await searchQortalNames('alice')).toEqual([]); + }); + + it('returns an empty array when the network request rejects', async () => { + const fetchMock = vi.mocked(fetch); + fetchMock.mockRejectedValue(new Error('network down')); + + expect(await searchQortalNames('alice')).toEqual([]); + }); + + it('returns an empty array when the response is not ok', async () => { + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValue({ + ok: false, + status: 503, + } as unknown as Response); + + expect(await searchQortalNames('alice')).toEqual([]); + }); + }); + + describe('getQortalNameData', () => { + it('returns null without calling the API for a blank name', async () => { + const fetchMock = vi.mocked(fetch); + + expect(await getQortalNameData(' ')).toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('resolves directly from the /names/{name} endpoint when it returns valid data', async () => { + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValue( + okJson({ name: 'Alice', owner: 'QAlice' }) as unknown as Response + ); + + expect(await getQortalNameData('Alice')).toEqual({ + name: 'Alice', + owner: 'QAlice', + }); + // Only the direct lookup is needed; no fallback search. + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][0]).toBe('/names/Alice'); + }); + + it('falls back to a case-insensitive exact search match when the direct lookup is invalid', async () => { + const fetchMock = vi.mocked(fetch); + fetchMock.mockImplementation((async (url: string) => { + if (url.startsWith('/names/search')) { + return okJson([ + { name: 'alice', owner: 'QAlice' }, + { name: 'aliceother', owner: 'QOther' }, + ]) as unknown as Response; + } + // Direct /names/{name} returns an unusable payload. + return okJson({}) as unknown as Response; + }) as unknown as typeof fetch); + + expect(await getQortalNameData('Alice')).toEqual({ + name: 'alice', + owner: 'QAlice', + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('falls back to search when the direct lookup throws, then returns the exact match', async () => { + const fetchMock = vi.mocked(fetch); + fetchMock.mockImplementation((async (url: string) => { + if (url.startsWith('/names/search')) { + return okJson([ + { name: 'Alice', owner: 'QAlice' }, + ]) as unknown as Response; + } + throw new Error('direct lookup failed'); + }) as unknown as typeof fetch); + + expect(await getQortalNameData('Alice')).toEqual({ + name: 'Alice', + owner: 'QAlice', + }); + }); + + it('returns null when neither the direct lookup nor the search yields an exact match', async () => { + const fetchMock = vi.mocked(fetch); + fetchMock.mockImplementation((async (url: string) => { + if (url.startsWith('/names/search')) { + return okJson([ + { name: 'alicia', owner: 'QAlicia' }, + ]) as unknown as Response; + } + return okJson({}) as unknown as Response; + }) as unknown as typeof fetch); + + expect(await getQortalNameData('Alice')).toBeNull(); + }); + }); +}); diff --git a/src/utils/addressBookQDN.ts b/src/utils/addressBookQDN.ts index 48b3298..289a6c2 100644 --- a/src/utils/addressBookQDN.ts +++ b/src/utils/addressBookQDN.ts @@ -1,6 +1,15 @@ import { base64ToObject, Coin, objectToBase64 } from 'qapp-core'; import { AddressBookEntry } from './Types'; -import { getAddressBook } from './addressBookStorage'; +import type { AddressBookLocalStorage } from './addressBookStorage'; +import { + createAddressBookSyncSignature, + getAddressBook, + getAddressBookPublishedHashKey, + getAddressBookSyncBaselineKey, + getAddressBookStorageKey, + getAddressBookSyncRequiredKey, + saveAddressBookSnapshot, +} from './addressBookStorage'; /** * Get all available coin types from the Coin enum @@ -36,26 +45,34 @@ export interface AddressBookQDNData { hash?: string; // Optional: hash of entries for quick comparison } -/** - * Interface for localStorage structure with metadata - */ -interface AddressBookLocalStorage { - entries: AddressBookEntry[]; - lastUpdated: number; -} - /** * Debounce timeouts for each coin type */ let publishTimeouts: { [coinType: string]: NodeJS.Timeout } = {}; +const isFiniteNumber = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value); + +const normalizeEntryForHash = (entry: AddressBookEntry) => ({ + address: entry.address.trim(), + coinType: entry.coinType, + favorite: Boolean(entry.favorite), + favoriteAt: isFiniteNumber(entry.favoriteAt) ? entry.favoriteAt : undefined, + id: entry.id, + name: entry.name.trim(), + note: (entry.note || '').trim(), + sortOrder: isFiniteNumber(entry.sortOrder) ? entry.sortOrder : undefined, +}); + /** - * Generates a hash of the entries for quick comparison - * Sorts entries by ID before hashing to ensure order-independence + * Generates a hash of the entries for quick comparison. + * Sorts entries by ID for stable hashing and ignores internal timestamps. */ function generateHash(entries: AddressBookEntry[]): string { - // Sort entries by ID to ensure consistent hash regardless of order - const sortedEntries = [...entries].sort((a, b) => a.id.localeCompare(b.id)); + // Sort entries by ID so hashes are stable when the same entries are loaded. + const sortedEntries = [...entries] + .map(normalizeEntryForHash) + .sort((a, b) => a.id.localeCompare(b.id)); const dataString = JSON.stringify(sortedEntries); // Simple hash function (djb2 algorithm variant) @@ -75,13 +92,24 @@ function generateHash(entries: AddressBookEntry[]): string { * @param entries - The address book entries to publish * @param userName - Optional username (if not provided, will attempt to fetch) */ -/** - * Key that records the hash of the last successfully published snapshot. - * Used to skip re-publishing when QDN is temporarily unavailable (propagation - * delay) but the local content has not changed since the last publish. - */ -function publishedHashKey(coinType: string): string { - return `q-wallets-addressbook-published-${coinType}`; +function markSyncRequired( + coinType: string, + cleanBaselineEntries?: AddressBookEntry[] +): void { + localStorage.setItem(getAddressBookSyncRequiredKey(coinType), 'true'); + if (cleanBaselineEntries) { + localStorage.setItem( + getAddressBookSyncBaselineKey(coinType), + createAddressBookSyncSignature(cleanBaselineEntries) + ); + return; + } + + localStorage.removeItem(getAddressBookSyncBaselineKey(coinType)); +} + +function clearSyncRequired(coinType: string): void { + localStorage.removeItem(getAddressBookSyncRequiredKey(coinType)); } async function publishToQDN( @@ -131,7 +159,8 @@ async function publishToQDN( // Record the published hash so future startup syncs can detect when QDN // is temporarily unavailable vs genuinely missing. - localStorage.setItem(publishedHashKey(coinType), hash); + localStorage.setItem(getAddressBookPublishedHashKey(coinType), hash); + clearSyncRequired(coinType); console.log( `QDN Sync: Published ${coinType} address book for user ${actualUserName}` @@ -282,9 +311,10 @@ async function fetchFromQDN( } /** - * Syncs a single address book on startup - * Compares timestamps to determine which version is newer - * Uses hash comparison when timestamps are equal + * Syncs a single address book on startup. + * This startup path is read-only for QDN: it can fetch newer remote data or + * mark local data as needing a user-initiated sync, but it must not publish + * because publishing opens a fee-bearing Qortal permission dialog. * @param coinType - The coin type to sync * @param userName - Optional username (if not provided, will attempt to fetch) */ @@ -297,39 +327,31 @@ async function syncAddressBookOnStartup( const localEntries = getAddressBook(coinType as Coin); const qdnData = await fetchFromQDN(coinType, userName); - // If no QDN data exists, publish local data if any + // If no QDN data exists, leave local data alone and wait for an + // explicit user sync instead of opening a publish permission dialog. if (!qdnData) { if (localEntries.length > 0) { - // Root cause 2 fix: skip re-publishing when QDN is temporarily - // unavailable (propagation delay) but content hasn't changed since the - // last successful publish — prevents a repeated permission dialog. + // Last successful publish sentinel prevents a repeated permission dialog. const lastPublishedHash = localStorage.getItem( - publishedHashKey(coinType) + getAddressBookPublishedHashKey(coinType) ); if (lastPublishedHash === generateHash(localEntries)) { + clearSyncRequired(coinType); console.log( `QDN Sync: ${coinType} QDN unavailable but content matches last publish, skipping` ); return; } - console.log(`QDN Sync: No QDN data, publishing local ${coinType} data`); - const publishedAt = await publishToQDN(coinType, localEntries, userName); - if (publishedAt !== null) { - // Align local timestamp with QDN so next startup goes to the - // equal-timestamp path rather than re-entering the "local is newer" path. - const localStorageKey = `q-wallets-addressbook-${coinType}`; - const dataToStore: AddressBookLocalStorage = { - entries: localEntries, - lastUpdated: publishedAt, - }; - localStorage.setItem(localStorageKey, JSON.stringify(dataToStore)); - } + console.log( + `QDN Sync: No QDN data for ${coinType}; manual sync required` + ); + markSyncRequired(coinType); } return; } // Get local last updated timestamp from localStorage metadata - const localStorageKey = `q-wallets-addressbook-${coinType}`; + const localStorageKey = getAddressBookStorageKey(coinType); const localData = localStorage.getItem(localStorageKey); let localLastUpdated = 0; @@ -351,44 +373,35 @@ async function syncAddressBookOnStartup( `QDN Sync: QDN data is newer for ${coinType}, updating localStorage` ); - // Save to localStorage with metadata - const dataToStore: AddressBookLocalStorage = { - entries: qdnData.entries, - lastUpdated: qdnData.lastUpdated, - }; - localStorage.setItem(localStorageKey, JSON.stringify(dataToStore)); + saveAddressBookSnapshot(coinType, qdnData.entries, qdnData.lastUpdated); + clearSyncRequired(coinType); } else if (localLastUpdated > qdnLastUpdated) { - // Local timestamp is newer, but check content before publishing to avoid - // unnecessary fees when timestamps diverge without actual data changes - // (e.g. debounce timing gap, old-format migration, clock skew) + // Local timestamp is newer, mirroring master's decision tree without + // auto-publishing. If content differs, keep local and ask the user to + // sync manually instead of opening a publish permission dialog. const localHash = generateHash(localEntries); const qdnHash = qdnData.hash ?? generateHash(qdnData.entries); if (localHash === qdnHash) { + clearSyncRequired(coinType); console.log( `QDN Sync: ${coinType} timestamps differ but content is identical, skipping publish` ); // Re-align local timestamp to QDN so future startups go straight to the // hash-comparison path instead of re-evaluating timestamps - const dataToStore: AddressBookLocalStorage = { - entries: localEntries, - lastUpdated: qdnData.lastUpdated, - }; - localStorage.setItem(localStorageKey, JSON.stringify(dataToStore)); + saveAddressBookSnapshot(coinType, localEntries, qdnData.lastUpdated); + } else if ( + localHash === + localStorage.getItem(getAddressBookPublishedHashKey(coinType)) + ) { + clearSyncRequired(coinType); + console.log( + `QDN Sync: ${coinType} local snapshot matches last publish; waiting for QDN propagation` + ); } else { console.log( - `QDN Sync: Local data is newer for ${coinType}, publishing to QDN` + `QDN Sync: Local ${coinType} data is newer and differs from QDN; manual sync required` ); - // Root cause 1 fix: use the timestamp actually stored inside QDN so - // local and QDN are identical after publish — next startup goes - // straight to the equal-timestamp path, no hash comparison needed. - const publishedAt = await publishToQDN(coinType, localEntries, userName); - if (publishedAt !== null) { - const dataToStore: AddressBookLocalStorage = { - entries: localEntries, - lastUpdated: publishedAt, - }; - localStorage.setItem(localStorageKey, JSON.stringify(dataToStore)); - } + markSyncRequired(coinType, qdnData.entries); } } else { // Same timestamp - use hash comparison if available @@ -398,13 +411,14 @@ async function syncAddressBookOnStartup( console.log( `QDN Sync: Hash mismatch for ${coinType}, using QDN data` ); - const dataToStore: AddressBookLocalStorage = { - entries: qdnData.entries, - lastUpdated: qdnData.lastUpdated, - }; - localStorage.setItem(localStorageKey, JSON.stringify(dataToStore)); + saveAddressBookSnapshot( + coinType, + qdnData.entries, + qdnData.lastUpdated + ); } } + clearSyncRequired(coinType); console.log(`QDN Sync: ${coinType} data is in sync`); } } catch (error) { diff --git a/src/utils/addressBookStorage.ts b/src/utils/addressBookStorage.ts index 9be631f..7ddde83 100644 --- a/src/utils/addressBookStorage.ts +++ b/src/utils/addressBookStorage.ts @@ -1,33 +1,190 @@ import { Coin } from 'qapp-core'; import { AddressBookEntry } from './Types'; -import { ADDRESSBOOK_NAME_LENGTH, ADDRESSBOOK_NOTE_LENGTH } from '../common/constants'; -import { debouncedPublishToQDN } from './addressBookQDN'; +import { + ADDRESSBOOK_NAME_LENGTH, + ADDRESSBOOK_NOTE_LENGTH, +} from '../common/constants'; const STORAGE_KEY_PREFIX = 'q-wallets-addressbook'; +const SYNC_REQUIRED_KEY_PREFIX = 'q-wallets-addressbook-sync-required'; +const SYNC_BASELINE_KEY_PREFIX = 'q-wallets-addressbook-sync-baseline'; +const PUBLISHED_HASH_KEY_PREFIX = 'q-wallets-addressbook-published'; +export const ADDRESS_BOOK_STORAGE_EVENT = 'q-wallets-addressbook-storage'; + +let addressBookAccountScope: string | null = null; /** * Interface for localStorage structure with metadata */ -interface AddressBookLocalStorage { +export interface AddressBookLocalStorage { entries: AddressBookEntry[]; lastUpdated: number; } +const normalizeAccountScope = (accountId?: string | null): string | null => { + const trimmed = String(accountId ?? '').trim(); + return trimmed ? encodeURIComponent(trimmed) : null; +}; + +export const setAddressBookAccountScope = (accountId?: string | null): void => { + addressBookAccountScope = normalizeAccountScope(accountId); +}; + +export const getAddressBookAccountScope = (): string | null => + addressBookAccountScope; + +const getLegacyScopedKey = (prefix: string, coinType: Coin | string): string => + `${prefix}-${coinType}`; + +const getScopedKey = (prefix: string, coinType: Coin | string): string => + addressBookAccountScope + ? `${prefix}-${addressBookAccountScope}-${coinType}` + : getLegacyScopedKey(prefix, coinType); + /** * Get the localStorage key for a specific coin type */ -const getStorageKey = (coinType: Coin): string => { - return `${STORAGE_KEY_PREFIX}-${coinType}`; +export const getAddressBookStorageKey = (coinType: Coin | string): string => { + return getScopedKey(STORAGE_KEY_PREFIX, coinType); +}; + +export const getAddressBookSyncRequiredKey = ( + coinType: Coin | string +): string => { + return getScopedKey(SYNC_REQUIRED_KEY_PREFIX, coinType); +}; + +export const getAddressBookSyncBaselineKey = ( + coinType: Coin | string +): string => { + return getScopedKey(SYNC_BASELINE_KEY_PREFIX, coinType); +}; + +export const getAddressBookPublishedHashKey = ( + coinType: Coin | string +): string => { + return getScopedKey(PUBLISHED_HASH_KEY_PREFIX, coinType); +}; + +const migrateLegacyKeyToCurrentScope = ( + legacyKey: string, + scopedKey: string +): void => { + if (!addressBookAccountScope || legacyKey === scopedKey) return; + if (localStorage.getItem(scopedKey) !== null) return; + + const legacyValue = localStorage.getItem(legacyKey); + if (legacyValue === null) return; + + localStorage.setItem(scopedKey, legacyValue); + localStorage.removeItem(legacyKey); +}; + +const migrateLegacyAddressBookKeys = (coinType: Coin | string): void => { + migrateLegacyKeyToCurrentScope( + getLegacyScopedKey(STORAGE_KEY_PREFIX, coinType), + getAddressBookStorageKey(coinType) + ); + migrateLegacyKeyToCurrentScope( + getLegacyScopedKey(SYNC_REQUIRED_KEY_PREFIX, coinType), + getAddressBookSyncRequiredKey(coinType) + ); + migrateLegacyKeyToCurrentScope( + getLegacyScopedKey(SYNC_BASELINE_KEY_PREFIX, coinType), + getAddressBookSyncBaselineKey(coinType) + ); + migrateLegacyKeyToCurrentScope( + getLegacyScopedKey(PUBLISHED_HASH_KEY_PREFIX, coinType), + getAddressBookPublishedHashKey(coinType) + ); +}; + +const hasNumber = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value); + +const compareByName = (a: AddressBookEntry, b: AddressBookEntry) => + a.name.toLowerCase().localeCompare(b.name.toLowerCase()) || + a.address.localeCompare(b.address); + +const compareByManualOrder = (a: AddressBookEntry, b: AddressBookEntry) => { + const aHasOrder = hasNumber(a.sortOrder); + const bHasOrder = hasNumber(b.sortOrder); + + if (aHasOrder || bHasOrder) { + const aOrder = aHasOrder + ? (a.sortOrder ?? Number.MAX_SAFE_INTEGER) + : Number.MAX_SAFE_INTEGER; + const bOrder = bHasOrder + ? (b.sortOrder ?? Number.MAX_SAFE_INTEGER) + : Number.MAX_SAFE_INTEGER; + return aOrder - bOrder || compareByName(a, b); + } + + return compareByName(a, b); +}; + +export const sortAddressBookEntries = ( + entries: AddressBookEntry[] +): AddressBookEntry[] => + [...entries].sort((a, b) => { + const favoriteDelta = + Number(Boolean(b.favorite)) - Number(Boolean(a.favorite)); + if (favoriteDelta !== 0) return favoriteDelta; + + if (a.favorite && b.favorite) { + const favoriteOrder = + (b.favoriteAt ?? 0) - (a.favoriteAt ?? 0) || compareByManualOrder(a, b); + return favoriteOrder; + } + + return compareByManualOrder(a, b); + }); + +export const createAddressBookSyncSignature = ( + entries: AddressBookEntry[] +): string => + JSON.stringify( + sortAddressBookEntries(entries).map((entry) => ({ + address: entry.address.trim(), + coinType: entry.coinType, + favorite: Boolean(entry.favorite), + name: entry.name.trim(), + note: (entry.note || '').trim(), + sortKey: entry.id, + })) + ); + +export const saveAddressBookSnapshot = ( + coinType: Coin | string, + entries: AddressBookEntry[], + lastUpdated: number +) => { + migrateLegacyAddressBookKeys(coinType); + const key = getAddressBookStorageKey(coinType); + const storageData: AddressBookLocalStorage = { + entries, + lastUpdated, + }; + + localStorage.setItem(key, JSON.stringify(storageData)); +}; + +const saveAddressBookEntries = ( + coinType: Coin, + entries: AddressBookEntry[] +) => { + saveAddressBookSnapshot(coinType, entries, Date.now()); }; /** * Retrieve all addresses for a specific coin type - * Returns addresses sorted alphabetically by name + * Returns addresses sorted by favorite state, manual order, then name * Handles both old format (array) and new format (object with metadata) */ export const getAddressBook = (coinType: Coin): AddressBookEntry[] => { try { - const key = getStorageKey(coinType); + migrateLegacyAddressBookKeys(coinType); + const key = getAddressBookStorageKey(coinType); const data = localStorage.getItem(key); if (!data) { @@ -53,19 +210,19 @@ export const getAddressBook = (coinType: Coin): AddressBookEntry[] => { entries = parsed.entries || []; } - // Sort by name alphabetically (case-insensitive) - return entries.sort((a, b) => - a.name.toLowerCase().localeCompare(b.name.toLowerCase()) - ); + return sortAddressBookEntries(entries); } catch (error) { - console.error(`Address Book: Error loading addresses for ${coinType}`, error); + console.error( + `Address Book: Error loading addresses for ${coinType}`, + error + ); return []; } }; /** * Add a new address to the address book - * Stores with metadata and triggers QDN sync + * Stores with metadata. QDN publishing is user-initiated. */ export const addAddress = ( entry: Omit @@ -73,12 +230,16 @@ export const addAddress = ( try { // Validate name length if (entry.name.length > ADDRESSBOOK_NAME_LENGTH) { - throw new Error(`Name must be ${ADDRESSBOOK_NAME_LENGTH} characters or less`); + throw new Error( + `Name must be ${ADDRESSBOOK_NAME_LENGTH} characters or less` + ); } // Validate note length if (entry.note.length > ADDRESSBOOK_NOTE_LENGTH) { - throw new Error(`Note must be ${ADDRESSBOOK_NOTE_LENGTH} characters or less`); + throw new Error( + `Note must be ${ADDRESSBOOK_NOTE_LENGTH} characters or less` + ); } // Get existing addresses @@ -86,7 +247,7 @@ export const addAddress = ( // Check for duplicate address const duplicateAddress = existingAddresses.find( - existing => existing.address === entry.address + (existing) => existing.address === entry.address ); if (duplicateAddress) { @@ -101,21 +262,14 @@ export const addAddress = ( }; // Add new entry - const updatedAddresses = [...existingAddresses, newEntry]; - - // Save to localStorage with metadata - const key = getStorageKey(entry.coinType); - const storageData: AddressBookLocalStorage = { - entries: updatedAddresses, - lastUpdated: Date.now(), - }; - localStorage.setItem(key, JSON.stringify(storageData)); + const updatedAddresses = sortAddressBookEntries([ + ...existingAddresses, + newEntry, + ]); + saveAddressBookEntries(entry.coinType, updatedAddresses); console.log(`Address Book: Added ${entry.name} for ${entry.coinType}`); - // Trigger QDN sync (debounced, async, don't wait) - debouncedPublishToQDN(entry.coinType, updatedAddresses); - return newEntry; } catch (error) { console.error('Address Book: Error adding address', error); @@ -126,7 +280,7 @@ export const addAddress = ( /** * Update an existing address in the address book * Returns the updated entry or null if not found - * Stores with metadata and triggers QDN sync + * Stores with metadata. QDN publishing is user-initiated. */ export const updateAddress = ( id: string, @@ -136,22 +290,28 @@ export const updateAddress = ( try { // Validate name length if provided if (updates.name && updates.name.length > ADDRESSBOOK_NAME_LENGTH) { - throw new Error(`Name must be ${ADDRESSBOOK_NAME_LENGTH} characters or less`); + throw new Error( + `Name must be ${ADDRESSBOOK_NAME_LENGTH} characters or less` + ); } // Validate note length if provided if (updates.note && updates.note.length > ADDRESSBOOK_NOTE_LENGTH) { - throw new Error(`Note must be ${ADDRESSBOOK_NOTE_LENGTH} characters or less`); + throw new Error( + `Note must be ${ADDRESSBOOK_NOTE_LENGTH} characters or less` + ); } // Get existing addresses const addresses = getAddressBook(coinType); // Find the entry to update - const index = addresses.findIndex(entry => entry.id === id); + const index = addresses.findIndex((entry) => entry.id === id); if (index === -1) { - console.warn(`Address Book: Entry with ID ${id} not found for ${coinType}`); + console.warn( + `Address Book: Entry with ID ${id} not found for ${coinType}` + ); return null; } @@ -164,19 +324,11 @@ export const updateAddress = ( addresses[index] = updatedEntry; - // Save to localStorage with metadata - const key = getStorageKey(coinType); - const storageData: AddressBookLocalStorage = { - entries: addresses, - lastUpdated: Date.now(), - }; - localStorage.setItem(key, JSON.stringify(storageData)); + const updatedAddresses = sortAddressBookEntries(addresses); + saveAddressBookEntries(coinType, updatedAddresses); console.log(`Address Book: Updated ${updatedEntry.name} for ${coinType}`); - // Trigger QDN sync (debounced, async, don't wait) - debouncedPublishToQDN(coinType, addresses); - return updatedEntry; } catch (error) { console.error('Address Book: Error updating address', error); @@ -187,7 +339,7 @@ export const updateAddress = ( /** * Delete an address from the address book * Returns true if deleted, false if not found - * Stores with metadata and triggers QDN sync + * Stores with metadata. QDN publishing is user-initiated. */ export const deleteAddress = (id: string, coinType: Coin): boolean => { try { @@ -195,30 +347,22 @@ export const deleteAddress = (id: string, coinType: Coin): boolean => { const addresses = getAddressBook(coinType); // Find the entry to delete - const index = addresses.findIndex(entry => entry.id === id); + const index = addresses.findIndex((entry) => entry.id === id); if (index === -1) { - console.warn(`Address Book: Entry with ID ${id} not found for ${coinType}`); + console.warn( + `Address Book: Entry with ID ${id} not found for ${coinType}` + ); return false; } // Remove the entry addresses.splice(index, 1); - // Save to localStorage with metadata - const key = getStorageKey(coinType); - const storageData: AddressBookLocalStorage = { - entries: addresses, - lastUpdated: Date.now(), - }; - - localStorage.setItem(key, JSON.stringify(storageData)); + saveAddressBookEntries(coinType, addresses); console.log(`Address Book: Deleted entry for ${coinType}`); - // Trigger QDN sync (debounced, async, don't wait) - debouncedPublishToQDN(coinType, addresses); - return true; } catch (error) { console.error('Address Book: Error deleting address', error); @@ -244,10 +388,11 @@ export const searchAddresses = ( const lowerQuery = query.toLowerCase(); // Filter by name, address or note (case-insensitive, partial match) - const filtered = addresses.filter(entry => - entry.name.toLowerCase().includes(lowerQuery) || - entry.address.toLowerCase().includes(lowerQuery) || - entry.note.toLowerCase().includes(lowerQuery) + const filtered = addresses.filter( + (entry) => + entry.name.toLowerCase().includes(lowerQuery) || + entry.address.toLowerCase().includes(lowerQuery) || + entry.note.toLowerCase().includes(lowerQuery) ); return filtered; @@ -256,3 +401,72 @@ export const searchAddresses = ( return []; } }; + +export const toggleAddressBookFavorite = ( + id: string, + coinType: Coin +): AddressBookEntry[] | null => { + try { + const addresses = getAddressBook(coinType); + const index = addresses.findIndex((entry) => entry.id === id); + + if (index === -1) { + console.warn( + `Address Book: Entry with ID ${id} not found for ${coinType}` + ); + return null; + } + + const entry = addresses[index]; + addresses[index] = { + ...entry, + favorite: !entry.favorite, + favoriteAt: entry.favorite ? undefined : Date.now(), + updatedAt: Date.now(), + }; + + const updatedAddresses = sortAddressBookEntries(addresses); + saveAddressBookEntries(coinType, updatedAddresses); + return updatedAddresses; + } catch (error) { + console.error('Address Book: Error toggling favorite', error); + throw error; + } +}; + +export const moveAddressBookEntry = ( + coinType: Coin, + sourceId: string, + targetId: string +): AddressBookEntry[] | null => { + try { + if (sourceId === targetId) return getAddressBook(coinType); + + const addresses = getAddressBook(coinType); + const sourceIndex = addresses.findIndex((entry) => entry.id === sourceId); + const targetIndex = addresses.findIndex((entry) => entry.id === targetId); + + if (sourceIndex === -1 || targetIndex === -1) { + console.warn(`Address Book: Unable to reorder entries for ${coinType}`); + return null; + } + + const reordered = [...addresses]; + const [movedEntry] = reordered.splice(sourceIndex, 1); + reordered.splice(targetIndex, 0, movedEntry); + + const favoriteTimestampBase = Date.now() + reordered.length; + const updatedAddresses = reordered.map((entry, index) => ({ + ...entry, + favoriteAt: entry.favorite ? favoriteTimestampBase - index : undefined, + sortOrder: index, + updatedAt: Date.now(), + })); + + saveAddressBookEntries(coinType, updatedAddresses); + return sortAddressBookEntries(updatedAddresses); + } catch (error) { + console.error('Address Book: Error reordering entries', error); + throw error; + } +}; diff --git a/src/utils/invisibleCharacters.ts b/src/utils/invisibleCharacters.ts new file mode 100644 index 0000000..2059995 --- /dev/null +++ b/src/utils/invisibleCharacters.ts @@ -0,0 +1,7 @@ +export function hasInvisibleCharacters(str: string) { + const normalized = str.normalize('NFKC'); + + return /[\u00AD\u034F\u061C\u115F\u1160\u17B4\u17B5\u180B-\u180E\u2000-\u200F\u2028-\u202F\u205F-\u206F\u2800\u3164\uFEFF\uFFA0]/u.test( + normalized + ); +} diff --git a/src/utils/qortalNodeApi.ts b/src/utils/qortalNodeApi.ts new file mode 100644 index 0000000..117bd99 --- /dev/null +++ b/src/utils/qortalNodeApi.ts @@ -0,0 +1,140 @@ +const QORTAL_API_TIMEOUT_MS = 5000; + +export type QortalNameSearchResult = { + name: string; + owner: string; +}; + +export type QortalNameData = { + name: string; + owner: string; +}; + +const fetchJsonWithTimeout = async (url: string, signal?: AbortSignal) => { + const controller = new AbortController(); + const timeoutId = window.setTimeout(() => { + controller.abort(); + }, QORTAL_API_TIMEOUT_MS); + const abortRequest = () => controller.abort(); + + signal?.addEventListener('abort', abortRequest, { once: true }); + + try { + const response = await fetch(url, { + signal: controller.signal, + }); + + if (!response.ok) { + throw new Error(`Qortal API request failed: ${response.status}`); + } + + return response.json(); + } finally { + window.clearTimeout(timeoutId); + signal?.removeEventListener('abort', abortRequest); + } +}; + +const toNameSearchResults = (payload: unknown): QortalNameSearchResult[] => { + if (!Array.isArray(payload)) return []; + + return payload + .map((item) => { + if (!item || typeof item !== 'object') return null; + + const record = item as { name?: unknown; owner?: unknown }; + if (typeof record.name !== 'string' || typeof record.owner !== 'string') { + return null; + } + + return { + name: record.name, + owner: record.owner, + }; + }) + .filter((item): item is QortalNameSearchResult => Boolean(item)); +}; + +const toNameData = (payload: unknown): QortalNameData | null => { + if (!payload || typeof payload !== 'object') return null; + + const record = payload as { name?: unknown; owner?: unknown }; + if (typeof record.name !== 'string' || typeof record.owner !== 'string') { + return null; + } + + return { + name: record.name, + owner: record.owner, + }; +}; + +export const getQortalNameData = async ( + name: string, + signal?: AbortSignal +): Promise => { + const trimmedName = name.trim(); + if (!trimmedName) return null; + + let lastError: unknown = null; + + if (signal?.aborted) return null; + + try { + const payload = await fetchJsonWithTimeout( + `/names/${encodeURIComponent(trimmedName)}`, + signal + ); + const result = toNameData(payload); + + if (result) { + return result; + } + } catch (error) { + lastError = error; + } + + const searchResults = await searchQortalNames(trimmedName, 10, signal); + const exactMatch = searchResults.find( + (result) => result.name.toLowerCase() === trimmedName.toLowerCase() + ); + + if (exactMatch) { + return exactMatch; + } + + if (lastError) { + console.warn('QORT name lookup failed:', lastError); + } + + return null; +}; + +export const searchQortalNames = async ( + query: string, + limit = 10, + signal?: AbortSignal +) => { + const trimmedQuery = query.trim(); + if (!trimmedQuery) return []; + + const params = new URLSearchParams({ + limit: String(limit), + prefix: 'true', + query: trimmedQuery, + }); + + if (signal?.aborted) return []; + + try { + const payload = await fetchJsonWithTimeout( + `/names/search?${params.toString()}`, + signal + ); + return toNameSearchResults(payload); + } catch (error) { + console.warn('QORT name search failed:', error); + } + + return []; +};