diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 47fe8c23..18c727b8 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -24,18 +24,33 @@ jobs: key: ${{ runner.os }}-deno-${{ hashFiles('**/deno.lock') }} restore-keys: | ${{ runner.os }}-deno- - + - name: Install Dependencies run: deno install - name: Cache Dependencies run: deno cache src/index.tsx - - name: Run linter - run: deno task lint + - name: Get changed files + id: changed-files + uses: tj-actions/changed-files@v46 + with: + files: | + **/*.ts + **/*.tsx + + # Uncomment the following lines when you have figured out how to ignore files + # - name: Type check changed files + # if: steps.changed-files.outputs.all_changed_files != '' + # run: deno check ${{ steps.changed-files.outputs.all_changed_files }} + + - name: Run linter on changed files + if: steps.changed-files.outputs.all_changed_files != '' + run: deno task lint ${{ steps.changed-files.outputs.all_changed_files }} - - name: Check formatter - run: deno task format --check + - name: Check format on changed files + if: steps.changed-files.outputs.all_changed_files != '' + run: deno task format --check ${{ steps.changed-files.outputs.all_changed_files }} - name: Run tests run: deno task test diff --git a/deno.json b/deno.json index 36466202..6320ae08 100644 --- a/deno.json +++ b/deno.json @@ -7,6 +7,7 @@ "@layouts/": "./src/layouts/", "@std/path": "jsr:@std/path@^1.1.0" }, + "include": ["src", "./vite-env.d.ts"], "compilerOptions": { "lib": [ "DOM", @@ -24,26 +25,35 @@ "types": [ "vite/client", "node", - "@types/web-bluetooth", - "@types/w3c-web-serial" + "npm:@types/w3c-web-serial", + "npm:@types/web-bluetooth" ], "strictPropertyInitialization": false }, "fmt": { "exclude": [ - "src/*.gen.ts", + "src/routeTree.gen.ts", "*.test.ts", "*.test.tsx" ] }, "lint": { "exclude": [ - "src/*.gen.ts", + "src/routeTree.gen.ts", "*.test.ts", "*.test.tsx" ], "report": "pretty" }, + "exclude": [ + "routeTree.gen.ts", + "node_modules/", + "dist", + "build", + "coverage", + "out", + ".vscode-test" + ], "unstable": [ "sloppy-imports" ] diff --git a/deno.lock b/deno.lock index 0300c35a..d63863ec 100644 --- a/deno.lock +++ b/deno.lock @@ -5,6 +5,7 @@ "jsr:@std/path@^1.1.0": "1.1.0", "npm:@bufbuild/protobuf@^2.2.5": "2.2.5", "npm:@jsr/meshtastic__core@2.6.2": "2.6.2", + "npm:@jsr/meshtastic__core@2.6.4": "2.6.4", "npm:@jsr/meshtastic__js@2.6.0-0": "2.6.0-0", "npm:@jsr/meshtastic__transport-http@*": "0.2.1", "npm:@jsr/meshtastic__transport-web-bluetooth@*": "0.1.1", @@ -41,12 +42,13 @@ "npm:@types/react-dom@^19.1.3": "19.1.3_@types+react@19.1.2", "npm:@types/react@^19.1.2": "19.1.2", "npm:@types/serviceworker@^0.0.133": "0.0.133", + "npm:@types/w3c-web-serial@*": "1.0.8", "npm:@types/w3c-web-serial@^1.0.8": "1.0.8", + "npm:@types/web-bluetooth@*": "0.0.21", "npm:@types/web-bluetooth@^0.0.21": "0.0.21", "npm:@vitejs/plugin-react@^4.4.1": "4.4.1_vite@6.3.4__@types+node@22.15.3__picomatch@4.0.2_@babel+core@7.27.1_@types+node@22.15.3", "npm:autoprefixer@^10.4.21": "10.4.21_postcss@8.5.3", "npm:base64-js@^1.5.1": "1.5.1", - "npm:class-validator@~0.14.2": "0.14.2", "npm:class-variance-authority@~0.7.1": "0.7.1", "npm:clsx@^2.1.1": "2.1.1", "npm:cmdk@^1.1.1": "1.1.1_react@19.1.0_react-dom@19.1.0__react@19.1.0_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2", @@ -77,14 +79,13 @@ "npm:tar@^7.4.3": "7.4.3", "npm:testing-library@^0.0.2": "0.0.2_@angular+common@6.1.10__@angular+core@6.1.10___rxjs@6.6.7___zone.js@0.8.29__rxjs@6.6.7_@angular+core@6.1.10__rxjs@6.6.7__zone.js@0.8.29", "npm:typescript@^5.8.3": "5.8.3", - "npm:vite-plugin-i18n-ally@^6.0.1": "6.0.1_vite@6.3.4__@types+node@22.15.3__picomatch@4.0.2_@types+node@22.15.3", - "npm:vite-plugin-node-polyfills@0.23": "0.23.0_vite@6.3.4__@types+node@22.15.3__picomatch@4.0.2_@types+node@22.15.3", "npm:vite-plugin-pwa@1": "1.0.0_vite@6.3.4__@types+node@22.15.3__picomatch@4.0.2_workbox-build@7.3.0__ajv@8.17.1__@babel+core@7.27.1__rollup@2.79.2_workbox-window@7.3.0_@types+node@22.15.3", "npm:vite-plugin-static-copy@3": "3.0.0_vite@6.3.4__@types+node@22.15.3__picomatch@4.0.2_@types+node@22.15.3", "npm:vite@^6.3.4": "6.3.4_@types+node@22.15.3_picomatch@4.0.2", "npm:vitest@^3.1.2": "3.1.2_@types+node@22.15.3_happy-dom@17.4.6_vite@6.3.4__@types+node@22.15.3__picomatch@4.0.2", - "npm:zod@^3.24.3": "3.24.3", - "npm:zustand@5.0.4": "5.0.4_@types+react@19.1.2_immer@10.1.1_react@19.1.0" + "npm:zod@^3.25.0": "3.25.49", + "npm:zod@^3.25.62": "3.25.62", + "npm:zustand@5.0.5": "5.0.5_@types+react@19.1.2_immer@10.1.1_react@19.1.0" }, "jsr": { "@std/path@1.0.6": { @@ -1131,6 +1132,17 @@ ], "tarball": "https://npm.jsr.io/~/11/@jsr/meshtastic__core/2.6.2.tgz" }, + "@jsr/meshtastic__core@2.6.4": { + "integrity": "sha512-1Kz5DK6peFxluHOJR38vFwfgeJzMXTz+3p6TvibjILVhSQC2U1nu8aJbn6w5zhRqS+j79OmtrRvdzL6VNsTkkQ==", + "dependencies": [ + "@bufbuild/protobuf", + "@jsr/meshtastic__protobufs", + "crc", + "ste-simple-events", + "tslog" + ], + "tarball": "https://npm.jsr.io/~/11/@jsr/meshtastic__core/2.6.4.tgz" + }, "@jsr/meshtastic__js@2.6.0-0": { "integrity": "sha512-+xpZpxK6oUIVOuEs7C+LyxRr2druvc7UNNNTK9Rl8ioXj63Jz1uQXlYe2Gj0xjnRAiSQLR7QVaPef21BR/YTxA==", "dependencies": [ @@ -1152,21 +1164,21 @@ "@jsr/meshtastic__transport-http@0.2.1": { "integrity": "sha512-lmQKr3aIINKvtGROU4HchmSVqbZSbkIHqajowRRC8IAjsnR0zNTyxz210QyY4pFUF9hpcW3GRjwq5h/VO2JuGg==", "dependencies": [ - "@jsr/meshtastic__core" + "@jsr/meshtastic__core@2.6.2" ], "tarball": "https://npm.jsr.io/~/11/@jsr/meshtastic__transport-http/0.2.1.tgz" }, "@jsr/meshtastic__transport-web-bluetooth@0.1.1": { "integrity": "sha512-eAj23n/Pxe8hMjO/uYbI/C+l1s0tLm41EzvcLWQtLQyEKJpPP+/Eqc5lUmDeF7FVPS2IYhllFJvV8Ili7okHtQ==", "dependencies": [ - "@jsr/meshtastic__core" + "@jsr/meshtastic__core@2.6.2" ], "tarball": "https://npm.jsr.io/~/11/@jsr/meshtastic__transport-web-bluetooth/0.1.1.tgz" }, "@jsr/meshtastic__transport-web-serial@0.2.1": { "integrity": "sha512-yumjEGLkAuJYOC3aWKvZzbQqi/LnqaKfNpVCY7Ki7oLtAshNiZrBLiwiFhN7+ZR9FfMdJThyBMqREBDRRWTO1Q==", "dependencies": [ - "@jsr/meshtastic__core" + "@jsr/meshtastic__core@2.6.2" ], "tarball": "https://npm.jsr.io/~/11/@jsr/meshtastic__transport-web-serial/0.2.1.tgz" }, @@ -1241,23 +1253,6 @@ "@noble/hashes@1.8.0": { "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==" }, - "@nodelib/fs.scandir@2.1.5": { - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dependencies": [ - "@nodelib/fs.stat", - "run-parallel" - ] - }, - "@nodelib/fs.stat@2.0.5": { - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==" - }, - "@nodelib/fs.walk@1.2.8": { - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dependencies": [ - "@nodelib/fs.scandir", - "fastq" - ] - }, "@radix-ui/number@1.1.1": { "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==" }, @@ -2084,14 +2079,6 @@ "rollup@2.79.2" ] }, - "@rollup/plugin-inject@5.0.5": { - "integrity": "sha512-2+DEJbNBoPROPkgTDNe8/1YXWcqxbN5DTjASVIOx8HS+pITXushyNiBV56RB08zuptzz8gT3YfkqriTBVycepg==", - "dependencies": [ - "@rollup/pluginutils@5.1.4_rollup@2.79.2", - "estree-walker@2.0.2", - "magic-string@0.30.17" - ] - }, "@rollup/plugin-node-resolve@15.3.1_rollup@2.79.2": { "integrity": "sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==", "dependencies": [ @@ -2440,7 +2427,7 @@ "@tanstack/virtual-file-routes", "prettier", "tsx", - "zod" + "zod@3.25.49" ], "optionalPeers": [ "@tanstack/react-router" @@ -2467,7 +2454,7 @@ "chokidar", "unplugin", "vite", - "zod" + "zod@3.25.49" ], "optionalPeers": [ "@tanstack/react-router", @@ -4000,9 +3987,6 @@ "@types/trusted-types@2.0.7": { "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==" }, - "@types/validator@13.15.0": { - "integrity": "sha512-nh7nrWhLr6CBq9ldtw0wx+z9wKnnv/uTVLA9g/3/TcOYxbpOSZE+MhKPmWqU+K0NvThjhv12uD8MuqijB0WzEA==" - }, "@types/w3c-web-serial@1.0.8": { "integrity": "sha512-QQOT+bxQJhRGXoZDZGLs3ksLud1dMNnMiSQtBA0w8KXvLpXX4oM4TZb6J0GgJ8UbCaHo5s9/4VQT8uXy9JER2A==" }, @@ -4130,9 +4114,6 @@ "picomatch@2.3.1" ] }, - "argparse@2.0.1": { - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" - }, "aria-hidden@1.2.4": { "integrity": "sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A==", "dependencies": [ @@ -4170,24 +4151,6 @@ "is-array-buffer" ] }, - "asn1.js@4.10.1": { - "integrity": "sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw==", - "dependencies": [ - "bn.js@4.12.2", - "inherits", - "minimalistic-assert" - ] - }, - "assert@2.1.0": { - "integrity": "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==", - "dependencies": [ - "call-bind", - "is-nan", - "object-is", - "object.assign", - "util" - ] - }, "assertion-error@2.0.1": { "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==" }, @@ -4267,12 +4230,6 @@ "binary-extensions@2.3.0": { "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==" }, - "bn.js@4.12.2": { - "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==" - }, - "bn.js@5.2.2": { - "integrity": "sha512-v2YAxEmKaBLahNwE1mjp4WON6huMNeuDvagFZW+ASCuA/ku0bXR9hSMw0XpiqMoA3+rmnyck/tPRSFQkoC9Cuw==" - }, "brace-expansion@1.1.11": { "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "dependencies": [ @@ -4292,72 +4249,6 @@ "fill-range" ] }, - "brorand@1.1.0": { - "integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==" - }, - "browser-resolve@2.0.0": { - "integrity": "sha512-7sWsQlYL2rGLy2IWm8WL8DCTJvYLc/qlOnsakDac87SOoCd16WLsaAMdCiAqsTNHIe+SXfaqyxyo6THoWqs8WQ==", - "dependencies": [ - "resolve" - ] - }, - "browserify-aes@1.2.0": { - "integrity": "sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==", - "dependencies": [ - "buffer-xor", - "cipher-base", - "create-hash", - "evp_bytestokey", - "inherits", - "safe-buffer@5.2.1" - ] - }, - "browserify-cipher@1.0.1": { - "integrity": "sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w==", - "dependencies": [ - "browserify-aes", - "browserify-des", - "evp_bytestokey" - ] - }, - "browserify-des@1.0.2": { - "integrity": "sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A==", - "dependencies": [ - "cipher-base", - "des.js", - "inherits", - "safe-buffer@5.2.1" - ] - }, - "browserify-rsa@4.1.1": { - "integrity": "sha512-YBjSAiTqM04ZVei6sXighu679a3SqWORA3qZTEqZImnlkDIFtKc6pNutpjyZ8RJTjQtuYfeetkxM11GwoYXMIQ==", - "dependencies": [ - "bn.js@5.2.2", - "randombytes", - "safe-buffer@5.2.1" - ] - }, - "browserify-sign@4.2.3": { - "integrity": "sha512-JWCZW6SKhfhjJxO8Tyiiy+XYB7cqd2S5/+WeYHsKdNKFlCBhKbblba1A/HN/90YwtxKc8tCErjffZl++UNmGiw==", - "dependencies": [ - "bn.js@5.2.2", - "browserify-rsa", - "create-hash", - "create-hmac", - "elliptic", - "hash-base", - "inherits", - "parse-asn1", - "readable-stream@2.3.8", - "safe-buffer@5.2.1" - ] - }, - "browserify-zlib@0.2.0": { - "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==", - "dependencies": [ - "pako" - ] - }, "browserslist@4.24.5": { "integrity": "sha512-FDToo4Wo82hIdgc1CQ+NQD0hEhmpPjrZ3hiUgwgOG6IuTdlpr8jdjyG24P6cNP1yJpTLzS5OcGgSw0xmDU1/Tw==", "dependencies": [ @@ -4371,26 +4262,6 @@ "buffer-from@1.1.2": { "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" }, - "buffer-xor@1.0.3": { - "integrity": "sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ==" - }, - "buffer@5.7.1": { - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dependencies": [ - "base64-js", - "ieee754" - ] - }, - "builtin-status-codes@3.0.0": { - "integrity": "sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ==" - }, - "bundle-require@5.1.0_esbuild@0.25.3": { - "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", - "dependencies": [ - "esbuild", - "load-tsconfig" - ] - }, "bytewise-core@1.2.3": { "integrity": "sha512-nZD//kc78OOxeYtRlVk8/zXqTB4gf/nlguL1ggWA8FuchMyOxcyHR4QPQZMUmA7czC+YnaBrPUCubqAWe50DaA==", "dependencies": [ @@ -4478,21 +4349,6 @@ "chownr@3.0.0": { "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==" }, - "cipher-base@1.0.6": { - "integrity": "sha512-3Ek9H3X6pj5TgenXYtNWdaBon1tgYCaebd+XPg0keyjEbEfkD4KkmAxkQ/i1vYvxdcT5nscLBfq9VJRmCBcFSw==", - "dependencies": [ - "inherits", - "safe-buffer@5.2.1" - ] - }, - "class-validator@0.14.2": { - "integrity": "sha512-3kMVRF2io8N8pY1IFIXlho9r8IPUUIfHe2hYVtiebvAzU2XeQFXTv+XI4WX+TnXmtwXMDcjngcpkiPM0O9PvLw==", - "dependencies": [ - "@types/validator", - "libphonenumber-js", - "validator" - ] - }, "class-variance-authority@0.7.1": { "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", "dependencies": [ @@ -4543,18 +4399,9 @@ "tinyqueue@2.0.3" ] }, - "console-browserify@1.2.0": { - "integrity": "sha512-ZMkYO/LkF17QvCPqM0gxw8yUzigAOZOSWSHg91FH6orS7vcEj5dVZTidN2fQ14yBSdg97RqhSNwLUXInd52OTA==" - }, - "constants-browserify@1.0.0": { - "integrity": "sha512-xFxOwqIzR/e1k1gLiWEophSCMqXcwVHIH7akf7b/vxcUeGunlj3hvZaaqxwHsTgn+IndtkQJgSztIDWeumWJDQ==" - }, "convert-source-map@2.0.0": { "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" }, - "cookie@1.0.2": { - "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==" - }, "core-js-compat@3.42.0": { "integrity": "sha512-bQasjMfyDGyaeWKBIu33lHh9qlSR0MFE/Nmc6nMjf/iU9b3rSMdAYz1Baxrv4lPdGUsTqZudHA4jIGSJy0SWZQ==", "dependencies": [ @@ -4567,60 +4414,12 @@ "crc@4.3.2": { "integrity": "sha512-uGDHf4KLLh2zsHa8D8hIQ1H/HtFQhyHrc0uhHBcoKGol/Xnb+MPYfUMw7cvON6ze/GUESTudKayDcJC5HnJv1A==" }, - "create-ecdh@4.0.4": { - "integrity": "sha512-mf+TCx8wWc9VpuxfP2ht0iSISLZnt0JgWlrOKZiNqyUZWnjIaCIVNQArMHnCZKfEYRg6IM7A+NeJoN8gf/Ws0A==", - "dependencies": [ - "bn.js@4.12.2", - "elliptic" - ] - }, - "create-hash@1.2.0": { - "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==", - "dependencies": [ - "cipher-base", - "inherits", - "md5.js", - "ripemd160", - "sha.js" - ] - }, - "create-hmac@1.1.7": { - "integrity": "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==", - "dependencies": [ - "cipher-base", - "create-hash", - "inherits", - "ripemd160", - "safe-buffer@5.2.1", - "sha.js" - ] - }, - "create-require@1.1.1": { - "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==" - }, "cross-fetch@4.0.0": { "integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==", "dependencies": [ "node-fetch" ] }, - "crypto-browserify@3.12.1": { - "integrity": "sha512-r4ESw/IlusD17lgQi1O20Fa3qNnsckR126TdUuBgAu7GBYSIPvdNyONd3Zrxh0xCwA4+6w/TDArBPsMvhur+KQ==", - "dependencies": [ - "browserify-cipher", - "browserify-sign", - "create-ecdh", - "create-hash", - "create-hmac", - "diffie-hellman", - "hash-base", - "inherits", - "pbkdf2", - "public-encrypt", - "randombytes", - "randomfill" - ] - }, "crypto-random-string@2.0.0": { "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==" }, @@ -4681,9 +4480,6 @@ "deep-eql@5.0.2": { "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==" }, - "deep-object-diff@1.1.9": { - "integrity": "sha512-Rn+RuwkmkDwCi2/oXOFS9Gsr5lJZu/yTGpK7wAaAIE75CC+LCGEZHpY6VQJa/RoJcrmaA/docWJZvYohlNkWPA==" - }, "deepmerge@4.3.1": { "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==" }, @@ -4706,13 +4502,6 @@ "dequal@2.0.3": { "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==" }, - "des.js@1.1.0": { - "integrity": "sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==", - "dependencies": [ - "inherits", - "minimalistic-assert" - ] - }, "detect-libc@2.0.4": { "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==" }, @@ -4722,23 +4511,12 @@ "diff@7.0.0": { "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==" }, - "diffie-hellman@5.0.3": { - "integrity": "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==", - "dependencies": [ - "bn.js@4.12.2", - "miller-rabin", - "randombytes" - ] - }, "dom-accessibility-api@0.5.16": { "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==" }, "dom-accessibility-api@0.6.3": { "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==" }, - "domain-browser@4.22.0": { - "integrity": "sha512-IGBwjF7tNk3cwypFNH/7bfzBcgSCbaMOD3GsaY1AU/JRrnHnYgEM0+9kQt52iZxjNsjBtJYtao146V+f8jFZNw==" - }, "dunder-proto@1.0.1": { "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "dependencies": [ @@ -4775,18 +4553,6 @@ "electron-to-chromium@1.5.149": { "integrity": "sha512-UyiO82eb9dVOx8YO3ajDf9jz2kKyt98DEITRdeLPstOEuTlLzDA4Gyq5K9he71TQziU5jUVu2OAu5N48HmQiyQ==" }, - "elliptic@6.6.1": { - "integrity": "sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==", - "dependencies": [ - "bn.js@4.12.2", - "brorand", - "hash.js", - "hmac-drbg", - "inherits", - "minimalistic-assert", - "minimalistic-crypto-utils" - ] - }, "end-of-stream@1.4.4": { "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", "dependencies": [ @@ -4938,16 +4704,6 @@ "esutils@2.0.3": { "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==" }, - "events@3.3.0": { - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==" - }, - "evp_bytestokey@1.0.3": { - "integrity": "sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==", - "dependencies": [ - "md5.js", - "safe-buffer@5.2.1" - ] - }, "expect-type@1.2.1": { "integrity": "sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==" }, @@ -4967,28 +4723,12 @@ "fast-deep-equal@3.1.3": { "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, - "fast-glob@3.3.3": { - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dependencies": [ - "@nodelib/fs.stat", - "@nodelib/fs.walk", - "glob-parent", - "merge2", - "micromatch" - ] - }, "fast-json-stable-stringify@2.1.0": { "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" }, "fast-uri@3.0.6": { "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==" }, - "fastq@1.19.1": { - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", - "dependencies": [ - "reusify" - ] - }, "fdir@6.4.4_picomatch@4.0.2": { "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", "dependencies": [ @@ -5010,21 +4750,6 @@ "to-regex-range" ] }, - "find-up@5.0.0": { - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dependencies": [ - "locate-path@6.0.0", - "path-exists@4.0.0" - ] - }, - "find-up@7.0.0": { - "integrity": "sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g==", - "dependencies": [ - "locate-path@7.2.0", - "path-exists@5.0.0", - "unicorn-magic" - ] - }, "for-each@0.3.5": { "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", "dependencies": [ @@ -5236,43 +4961,18 @@ "has-symbols" ] }, - "hash-base@3.0.5": { - "integrity": "sha512-vXm0l45VbcHEVlTCzs8M+s0VeYsB2lnlAaThoLKGXr3bE/VWDOelNUnycUPEhKEaXARL2TEFjBOyUiM6+55KBg==", - "dependencies": [ - "inherits", - "safe-buffer@5.2.1" - ] - }, - "hash.js@1.1.7": { - "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", - "dependencies": [ - "inherits", - "minimalistic-assert" - ] - }, "hasown@2.0.2": { "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "dependencies": [ "function-bind" ] }, - "hmac-drbg@1.0.1": { - "integrity": "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==", - "dependencies": [ - "hash.js", - "minimalistic-assert", - "minimalistic-crypto-utils" - ] - }, "html-parse-stringify@3.0.1": { "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", "dependencies": [ "void-elements" ] }, - "https-browserify@1.0.0": { - "integrity": "sha512-J+FkSdyD+0mA0N+81tMotaRMfSL9SGi+xpD3T6YApKsc3bGSXJlfXri3VyFOeYkfLRQisDk1W+jIFFKBeUBbBg==" - }, "i18next-browser-languagedetector@8.1.0": { "integrity": "sha512-mHZxNx1Lq09xt5kCauZ/4bsXOEA2pfpwSoU11/QTJB+pD94iONFwp+ohqi///PwiFvjFOxe1akYCdHyFo1ng5Q==", "dependencies": [ @@ -5307,17 +5007,6 @@ "immer@10.1.1": { "integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==" }, - "importx@0.5.2_esbuild@0.25.3": { - "integrity": "sha512-YEwlK86Ml5WiTxN/ECUYC5U7jd1CisAVw7ya4i9ZppBoHfFkT2+hChhr3PE2fYxUKLkNyivxEQpa5Ruil1LJBQ==", - "dependencies": [ - "bundle-require", - "debug", - "esbuild", - "jiti", - "pathe", - "tsx" - ] - }, "indent-string@4.0.0": { "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==" }, @@ -5343,13 +5032,6 @@ "side-channel" ] }, - "is-arguments@1.2.0": { - "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", - "dependencies": [ - "call-bound", - "has-tostringtag" - ] - }, "is-array-buffer@3.0.5": { "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", "dependencies": [ @@ -5450,13 +5132,6 @@ "is-module@1.0.0": { "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==" }, - "is-nan@1.3.2": { - "integrity": "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==", - "dependencies": [ - "call-bind", - "define-properties" - ] - }, "is-number-object@1.1.1": { "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", "dependencies": [ @@ -5552,9 +5227,6 @@ "isobject@3.0.1": { "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==" }, - "isomorphic-timers-promises@1.0.1": { - "integrity": "sha512-u4sej9B1LPSxTGKB/HiuzvEQnXH0ECYkSVQU39koSwmFAxhlEAFl9RdTvLv4TOTQUgBS5O3O5fwUxk6byBZ+IQ==" - }, "jake@10.9.2": { "integrity": "sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA==", "dependencies": [ @@ -5575,13 +5247,6 @@ "js-tokens@4.0.0": { "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, - "js-yaml@4.1.0": { - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dependencies": [ - "argparse" - ], - "bin": true - }, "jsesc@3.0.2": { "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", "bin": true @@ -5627,21 +5292,9 @@ "kind-of@6.0.3": { "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==" }, - "language-subtag-registry@0.3.23": { - "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==" - }, - "language-tags@2.1.0": { - "integrity": "sha512-D4CgpyCt+61f6z2jHjJS1OmZPviAWM57iJ9OKdFFWSNgS7Udj9QVWqyGs/cveVNF57XpZmhSvMdVIV5mjLA7Vg==", - "dependencies": [ - "language-subtag-registry" - ] - }, "leven@3.1.0": { "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==" }, - "libphonenumber-js@1.12.7": { - "integrity": "sha512-0nYZSNj/QEikyhcM5RZFXGlCB/mr4PVamnT1C2sKBnDDTYndrvbybYjvg+PMqAndQHlLbwQ3socolnL3WWTUFA==" - }, "lightningcss-darwin-arm64@1.29.2": { "integrity": "sha512-cK/eMabSViKn/PG8U/a7aCorpeKLMlK0bQeNHmdb7qUnBkNPnL+oV5DjJUo0kqWsJUapZsM4jCfYItbqBDvlcA==", "os": ["darwin"], @@ -5710,21 +5363,6 @@ "lightningcss-win32-x64-msvc" ] }, - "load-tsconfig@0.2.5": { - "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==" - }, - "locate-path@6.0.0": { - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dependencies": [ - "p-locate@5.0.0" - ] - }, - "locate-path@7.2.0": { - "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==", - "dependencies": [ - "p-locate@6.0.0" - ] - }, "lodash.debounce@4.0.8": { "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==" }, @@ -5806,41 +5444,9 @@ "math-intrinsics@1.1.0": { "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==" }, - "md5.js@1.3.5": { - "integrity": "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==", - "dependencies": [ - "hash-base", - "inherits", - "safe-buffer@5.2.1" - ] - }, - "merge2@1.4.1": { - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==" - }, - "micromatch@4.0.8": { - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dependencies": [ - "braces", - "picomatch@2.3.1" - ] - }, - "miller-rabin@4.0.1": { - "integrity": "sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==", - "dependencies": [ - "bn.js@4.12.2", - "brorand" - ], - "bin": true - }, "min-indent@1.0.1": { "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==" }, - "minimalistic-assert@1.0.1": { - "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==" - }, - "minimalistic-crypto-utils@1.0.1": { - "integrity": "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==" - }, "minimatch@3.1.2": { "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dependencies": [ @@ -5879,9 +5485,6 @@ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "bin": true }, - "negotiator@1.0.0": { - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==" - }, "node-fetch@2.7.0": { "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", "dependencies": [ @@ -5891,38 +5494,6 @@ "node-releases@2.0.19": { "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==" }, - "node-stdlib-browser@1.3.1": { - "integrity": "sha512-X75ZN8DCLftGM5iKwoYLA3rjnrAEs97MkzvSd4q2746Tgpg8b8XWiBGiBG4ZpgcAqBgtgPHTiAc8ZMCvZuikDw==", - "dependencies": [ - "assert", - "browser-resolve", - "browserify-zlib", - "buffer", - "console-browserify", - "constants-browserify", - "create-require", - "crypto-browserify", - "domain-browser", - "events", - "https-browserify", - "isomorphic-timers-promises", - "os-browserify", - "path-browserify", - "pkg-dir", - "process", - "punycode@1.4.1", - "querystring-es3", - "readable-stream@3.6.2", - "stream-browserify", - "stream-http", - "string_decoder@1.3.0", - "timers-browserify", - "tty-browserify", - "url", - "util", - "vm-browserify" - ] - }, "normalize-path@3.0.0": { "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==" }, @@ -5932,13 +5503,6 @@ "object-inspect@1.13.4": { "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==" }, - "object-is@1.1.6": { - "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", - "dependencies": [ - "call-bind", - "define-properties" - ] - }, "object-keys@1.1.1": { "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==" }, @@ -5959,9 +5523,6 @@ "wrappy" ] }, - "os-browserify@0.3.0": { - "integrity": "sha512-gjcpUc3clBf9+210TRaDWbf+rZZZEshZ+DlXMRCeAjp0xhTrnQsKHypIy1J3d5hKdUzj69t708EHtU8P6bUn0A==" - }, "own-keys@1.0.1": { "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", "dependencies": [ @@ -5970,56 +5531,9 @@ "safe-push-apply" ] }, - "p-limit@3.1.0": { - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dependencies": [ - "yocto-queue@0.1.0" - ] - }, - "p-limit@4.0.0": { - "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", - "dependencies": [ - "yocto-queue@1.2.1" - ] - }, - "p-locate@5.0.0": { - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dependencies": [ - "p-limit@3.1.0" - ] - }, - "p-locate@6.0.0": { - "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==", - "dependencies": [ - "p-limit@4.0.0" - ] - }, "p-map@7.0.3": { "integrity": "sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA==" }, - "pako@1.0.11": { - "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" - }, - "parse-asn1@5.1.7": { - "integrity": "sha512-CTM5kuWR3sx9IFamcl5ErfPl6ea/N8IYwiJ+vpeB2g+1iknv7zBl5uPwbMbRVznRVbrNY6lGuDoE5b30grmbqg==", - "dependencies": [ - "asn1.js", - "browserify-aes", - "evp_bytestokey", - "hash-base", - "pbkdf2", - "safe-buffer@5.2.1" - ] - }, - "path-browserify@1.0.1": { - "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==" - }, - "path-exists@4.0.0": { - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==" - }, - "path-exists@5.0.0": { - "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==" - }, "path-is-absolute@1.0.1": { "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==" }, @@ -6040,16 +5554,6 @@ ], "bin": true }, - "pbkdf2@3.1.2": { - "integrity": "sha512-iuh7L6jA7JEGu2WxDwtQP1ddOpaJNC4KlDEFfdQajSGgGPNi4OyDc2R7QnbY2bR9QjBVGwgvTdNJZoE7RaxUMA==", - "dependencies": [ - "create-hash", - "create-hmac", - "ripemd160", - "safe-buffer@5.2.1", - "sha.js" - ] - }, "peek-stream@1.1.3": { "integrity": "sha512-FhJ+YbOSBb9/rIl2ZeE/QHEsWn7PqNYt8ARAY3kIgNGOk13g9FGyIY6JIl/xB/3TFRVoTv5as0l11weORrTekA==", "dependencies": [ @@ -6067,12 +5571,6 @@ "picomatch@4.0.2": { "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==" }, - "pkg-dir@5.0.0": { - "integrity": "sha512-NPE8TDbzl/3YQYY7CSS228s3g2ollTFnc+Qi3tqmqJp9Vg2ovUpixcJEo2HJScN2Ez+kEaal6y70c0ehqJBJeA==", - "dependencies": [ - "find-up@5.0.0" - ] - }, "point-in-polygon-hao@1.2.4": { "integrity": "sha512-x2pcvXeqhRHlNRdhLs/tgFapAbSSe86wa/eqmj1G6pWftbEs5aVRJhRGM6FYSUERKu0PjekJzMq0gsI2XyiclQ==", "dependencies": [ @@ -6133,44 +5631,15 @@ "duplex-maker" ] }, - "process@0.11.10": { - "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==" - }, "protocol-buffers-schema@3.6.0": { "integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==" }, - "public-encrypt@4.0.3": { - "integrity": "sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q==", - "dependencies": [ - "bn.js@4.12.2", - "browserify-rsa", - "create-hash", - "parse-asn1", - "randombytes", - "safe-buffer@5.2.1" - ] - }, - "punycode@1.4.1": { - "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==" - }, "punycode@2.3.1": { "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==" }, "qrcode-generator@1.4.4": { "integrity": "sha512-HM7yY8O2ilqhmULxGMpcHSF1EhJJ9yBj8gvDEuZ6M+KGJ0YY2hKpnXvRD+hZPLrDVck3ExIGhmPtSdcjC+guuw==" }, - "qs@6.14.0": { - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", - "dependencies": [ - "side-channel" - ] - }, - "querystring-es3@0.2.1": { - "integrity": "sha512-773xhDQnZBMFobEiztv8LIl70ch5MSF/jUQVlhwFyBILqq96anmoctVIYz+ZRp0qbCKATTn6ev02M3r7Ga5vqA==" - }, - "queue-microtask@1.2.3": { - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==" - }, "quickselect@1.1.1": { "integrity": "sha512-qN0Gqdw4c4KGPsBOQafj6yj/PA6c/L63f6CaZ/DCF/xF4Esu3jVmKLUDYxghFx8Kb/O7y9tI7x2RjTSXwdK1iQ==" }, @@ -6186,13 +5655,6 @@ "safe-buffer@5.2.1" ] }, - "randomfill@1.0.4": { - "integrity": "sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw==", - "dependencies": [ - "randombytes", - "safe-buffer@5.2.1" - ] - }, "rbush@2.0.2": { "integrity": "sha512-XBOuALcTm+O/H8G90b6pzu6nX6v2zCKiFG4BJho8a+bY6AER6t8uQUZdi5bomQc0AprCWhEGa7ncAbbRap0bRA==", "dependencies": [ @@ -6416,19 +5878,9 @@ ], "bin": true }, - "reusify@1.1.0": { - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==" - }, "rfc4648@1.5.4": { "integrity": "sha512-rRg/6Lb+IGfJqO05HZkN50UtY7K/JhxJag1kP23+zyMfrvoB0B7RWv06MbOzoc79RgCdNTiUaNsTT1AJZ7Z+cg==" }, - "ripemd160@2.0.2": { - "integrity": "sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA==", - "dependencies": [ - "hash-base", - "inherits" - ] - }, "robust-predicates@2.0.4": { "integrity": "sha512-l4NwboJM74Ilm4VKfbAtFeGq7aEjWL+5kVFcmgFA2MrdnQWx9iE/tUGvxY5HyMI7o/WpSIUFLbC5fbeaHgSCYg==" }, @@ -6472,12 +5924,6 @@ ], "bin": true }, - "run-parallel@1.2.0": { - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dependencies": [ - "queue-microtask" - ] - }, "rw@1.3.3": { "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==" }, @@ -6577,17 +6023,6 @@ "split-string" ] }, - "setimmediate@1.0.5": { - "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==" - }, - "sha.js@2.4.11": { - "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==", - "dependencies": [ - "inherits", - "safe-buffer@5.2.1" - ], - "bin": true - }, "side-channel-list@1.0.0": { "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", "dependencies": [ @@ -6719,22 +6154,6 @@ "ste-core" ] }, - "stream-browserify@3.0.0": { - "integrity": "sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==", - "dependencies": [ - "inherits", - "readable-stream@3.6.2" - ] - }, - "stream-http@3.2.0": { - "integrity": "sha512-Oq1bLqisTyK3TSCXpPbT4sdeYNdmyZJv1LxpEm2vu1ZhK89kSE5YXwZc3cWk0MagGaKriBh9mCFbVGtO+vY29A==", - "dependencies": [ - "builtin-status-codes", - "inherits", - "readable-stream@3.6.2", - "xtend" - ] - }, "stream-shift@1.0.3": { "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==" }, @@ -6904,12 +6323,6 @@ "readable-stream@3.6.2" ] }, - "timers-browserify@2.0.12": { - "integrity": "sha512-9phl76Cqm6FhSX9Xe1ZUAMLtm1BLkKj2Qd5ApyWkXzsMRaA7dgr81kf4wJmQf/hAvg8EEyJxDo3du/0KlhPiKQ==", - "dependencies": [ - "setimmediate" - ] - }, "tiny-invariant@1.3.3": { "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==" }, @@ -6970,7 +6383,7 @@ "tr46@1.0.1": { "integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==", "dependencies": [ - "punycode@2.3.1" + "punycode" ] }, "tslib@1.14.1": { @@ -6993,9 +6406,6 @@ ], "bin": true }, - "tty-browserify@0.0.1": { - "integrity": "sha512-C3TaO7K81YvjCgQH9Q1S3R3P3BtN3RIM8n+OvX4il1K1zgE8ZhI0op7kClgkxtutIE8hQrcrHBXvIheqKUUCxw==" - }, "type-fest@0.16.0": { "integrity": "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==" }, @@ -7084,9 +6494,6 @@ "unicode-property-aliases-ecmascript@2.1.0": { "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==" }, - "unicorn-magic@0.1.0": { - "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==" - }, "union-value@1.0.1": { "integrity": "sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==", "dependencies": [ @@ -7125,13 +6532,6 @@ ], "bin": true }, - "url@0.11.4": { - "integrity": "sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==", - "dependencies": [ - "punycode@1.4.1", - "qs" - ] - }, "use-callback-ref@1.3.3_@types+react@19.1.2_react@19.1.0": { "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", "dependencies": [ @@ -7164,19 +6564,6 @@ "util-deprecate@1.0.2": { "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, - "util@0.12.5": { - "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", - "dependencies": [ - "inherits", - "is-arguments", - "is-generator-function", - "is-typed-array", - "which-typed-array" - ] - }, - "validator@13.15.0": { - "integrity": "sha512-36B2ryl4+oL5QxZ3AzD0t5SsMNGvTtQHpjgFO5tbNxfXbMFkY822ktCDe1MnlqV3301QQI9SLHDNJokDI+Z9pA==" - }, "vite-node@3.1.2_@types+node@22.15.3": { "integrity": "sha512-/8iMryv46J3aK13iUXsei5G/A3CUlW4665THCPS+K8xAaqrVWiGB4RfXMQXCLjpK9P2eK//BczrVkn5JLAk6DA==", "dependencies": [ @@ -7188,31 +6575,6 @@ ], "bin": true }, - "vite-plugin-i18n-ally@6.0.1_vite@6.3.4__@types+node@22.15.3__picomatch@4.0.2_@types+node@22.15.3": { - "integrity": "sha512-BmXlAkrmSRrbaho7iJpBf1d2EPyDK2oqY1AKVxkJEUikGDPducFpLfpmlqTyUNhsWZT01ZLWdjR2uIRnnVJXzw==", - "dependencies": [ - "cookie", - "debug", - "deep-object-diff", - "fast-glob", - "find-up@7.0.0", - "importx", - "js-yaml", - "json5", - "language-tags", - "negotiator", - "picocolors", - "vite" - ] - }, - "vite-plugin-node-polyfills@0.23.0_vite@6.3.4__@types+node@22.15.3__picomatch@4.0.2_@types+node@22.15.3": { - "integrity": "sha512-4n+Ys+2bKHQohPBKigFlndwWQ5fFKwaGY6muNDMTb0fSQLyBzS+jjUNRZG9sKF0S/Go4ApG6LFnUGopjkILg3w==", - "dependencies": [ - "@rollup/plugin-inject", - "node-stdlib-browser", - "vite" - ] - }, "vite-plugin-pwa@1.0.0_vite@6.3.4__@types+node@22.15.3__picomatch@4.0.2_workbox-build@7.3.0__ajv@8.17.1__@babel+core@7.27.1__rollup@2.79.2_workbox-window@7.3.0_@types+node@22.15.3": { "integrity": "sha512-X77jo0AOd5OcxmWj3WnVti8n7Kw2tBgV1c8MCXFclrSlDV23ePzv2eTDIALXI2Qo6nJ5pZJeZAuX0AawvRfoeA==", "dependencies": [ @@ -7287,9 +6649,6 @@ ], "bin": true }, - "vm-browserify@1.1.2": { - "integrity": "sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==" - }, "void-elements@3.1.0": { "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==" }, @@ -7547,20 +6906,17 @@ "yallist@5.0.0": { "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==" }, - "yocto-queue@0.1.0": { - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==" - }, - "yocto-queue@1.2.1": { - "integrity": "sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==" + "zod@3.25.49": { + "integrity": "sha512-JMMPMy9ZBk3XFEdbM3iL1brx4NUSejd6xr3ELrrGEfGb355gjhiAWtG3K5o+AViV/3ZfkIrCzXsZn6SbLwTR8Q==" }, - "zod@3.24.3": { - "integrity": "sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==" + "zod@3.25.62": { + "integrity": "sha512-YCxsr4DmhPcrKPC9R1oBHQNlQzlJEyPAId//qTau/vBee9uO8K6prmRq4eMkOyxvBfH4wDPIPdLx9HVMWIY3xA==" }, "zone.js@0.8.29": { "integrity": "sha512-mla2acNCMkWXBD+c+yeUrBUrzOxYMNFdQ6FGfigGGtEVBPJx07BQeJekjt9DmH1FtZek4E9rE1eRR9qQpxACOQ==" }, - "zustand@5.0.4_@types+react@19.1.2_immer@10.1.1_react@19.1.0": { - "integrity": "sha512-39VFTN5InDtMd28ZhjLyuTnlytDr9HfwO512Ai4I8ZABCoyAj4F1+sr7sD1jP/+p7k77Iko0Pb5NhgBFDCX0kQ==", + "zustand@5.0.5_@types+react@19.1.2_immer@10.1.1_react@19.1.0": { + "integrity": "sha512-mILtRfKW9xM47hqxGIxCv12gXusoY/xTSHBYApXozR0HmQv299whhBeeAcRy+KrPPybzosvJBCOmVjq6x12fCg==", "dependencies": [ "@types/react", "immer", @@ -7575,12 +6931,14 @@ }, "workspace": { "dependencies": [ - "jsr:@std/path@^1.1.0" + "jsr:@std/path@^1.1.0", + "npm:@types/w3c-web-serial@*", + "npm:@types/web-bluetooth@*" ], "packageJson": { "dependencies": [ "npm:@bufbuild/protobuf@^2.2.5", - "npm:@jsr/meshtastic__core@2.6.2", + "npm:@jsr/meshtastic__core@2.6.4", "npm:@jsr/meshtastic__js@2.6.0-0", "npm:@jsr/meshtastic__transport-http@*", "npm:@jsr/meshtastic__transport-web-bluetooth@*", @@ -7622,7 +6980,6 @@ "npm:@vitejs/plugin-react@^4.4.1", "npm:autoprefixer@^10.4.21", "npm:base64-js@^1.5.1", - "npm:class-validator@~0.14.2", "npm:class-variance-authority@~0.7.1", "npm:clsx@^2.1.1", "npm:cmdk@^1.1.1", @@ -7653,14 +7010,12 @@ "npm:tar@^7.4.3", "npm:testing-library@^0.0.2", "npm:typescript@^5.8.3", - "npm:vite-plugin-i18n-ally@^6.0.1", - "npm:vite-plugin-node-polyfills@0.23", "npm:vite-plugin-pwa@1", "npm:vite-plugin-static-copy@3", "npm:vite@^6.3.4", "npm:vitest@^3.1.2", - "npm:zod@^3.24.3", - "npm:zustand@5.0.4" + "npm:zod@^3.25.62", + "npm:zustand@5.0.5" ] } } diff --git a/package.json b/package.json index 77709964..f3577bc3 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "dev": "deno task dev:ui", "dev:ui": "VITE_APP_VERSION=development deno run -A npm:vite dev", "test": "deno run -A npm:vitest", + "check": "deno check", "preview": "deno run -A npm:vite preview", "package": "gzipper c -i html,js,css,png,ico,svg,webmanifest,txt dist dist/output && tar -cvf dist/build.tar -C ./dist/output/ ." }, @@ -35,10 +36,11 @@ "homepage": "https://meshtastic.org", "dependencies": { "@bufbuild/protobuf": "^2.2.5", - "@meshtastic/core": "npm:@jsr/meshtastic__core@2.6.2", - "@meshtastic/transport-http": "npm:@jsr/meshtastic__transport-http@0.2.1", - "@meshtastic/transport-web-bluetooth": "npm:@jsr/meshtastic__transport-web-bluetooth@0.1.2", - "@meshtastic/transport-web-serial": "npm:@jsr/meshtastic__transport-web-serial@0.2.1", + "@meshtastic/core": "npm:@jsr/meshtastic__core@2.6.4", + "@meshtastic/js": "npm:@jsr/meshtastic__js@2.6.0-0", + "@meshtastic/transport-http": "npm:@jsr/meshtastic__transport-http", + "@meshtastic/transport-web-bluetooth": "npm:@jsr/meshtastic__transport-web-bluetooth", + "@meshtastic/transport-web-serial": "npm:@jsr/meshtastic__transport-web-serial", "@noble/curves": "^1.9.0", "@radix-ui/react-accordion": "^1.2.8", "@radix-ui/react-checkbox": "^1.2.3", @@ -60,6 +62,7 @@ "@tanstack/react-router-devtools": "^1.120.16", "@tanstack/router-devtools": "^1.120.15", "@turf/turf": "^7.2.0", + "@types/web-bluetooth": "^0.0.21", "base64-js": "^1.5.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -81,10 +84,8 @@ "react-map-gl": "8.0.4", "react-qrcode-logo": "^3.0.0", "rfc4648": "^1.5.4", - "vite-plugin-i18n-ally": "^6.0.1", - "vite-plugin-node-polyfills": "^0.23.0", - "zod": "^3.25.0", - "zustand": "5.0.4" + "zod": "^3.25.62", + "zustand": "5.0.5" }, "devDependencies": { "@tailwindcss/postcss": "^4.1.5", @@ -93,13 +94,12 @@ "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", "@types/chrome": "^0.0.318", - "@types/js-cookie": "^3.0.6", "@types/node": "^22.15.3", "@types/react": "^19.1.2", "@types/react-dom": "^19.1.3", "@types/serviceworker": "^0.0.133", + "@types/js-cookie": "^3.0.6", "@types/w3c-web-serial": "^1.0.8", - "@types/web-bluetooth": "^0.0.21", "@vitejs/plugin-react": "^4.4.1", "autoprefixer": "^10.4.21", "gzipper": "^8.2.1", diff --git a/src/components/BatteryStatus.tsx b/src/components/BatteryStatus.tsx index c80bb87e..feb7ff67 100644 --- a/src/components/BatteryStatus.tsx +++ b/src/components/BatteryStatus.tsx @@ -8,56 +8,45 @@ import { import { useTranslation } from "react-i18next"; import { DeviceMetrics } from "./types.ts"; -interface BatteryStateConfig { - condition: (level: number) => boolean; - Icon: React.ElementType; - className: string; - text: (level: number) => string; +type BatteryStatusKey = keyof typeof BATTERY_STATUS; + +interface BatteryStatusProps { + deviceMetrics?: DeviceMetrics | null; } interface BatteryStatusProps { deviceMetrics?: DeviceMetrics | null; } -const getBatteryStates = ( - t: (key: string, options?: object) => string, -): BatteryStateConfig[] => { - return [ - { - condition: (level) => level > 100, - Icon: PlugZapIcon, - className: "text-gray-500", - text: () => t("batteryStatus.pluggedIn"), - }, - { - condition: (level) => level > 80, - Icon: BatteryFullIcon, - className: "text-green-500", - text: (level) => t("batteryStatus.charging", { level }), - }, - { - condition: (level) => level > 20, - Icon: BatteryMediumIcon, - className: "text-yellow-500", - text: (level) => t("batteryStatus.charging", { level }), - }, - { - condition: () => true, - Icon: BatteryLowIcon, - className: "text-red-500", - text: (level) => t("batteryStatus.charging", { level }), - }, - ]; -}; +interface StatusConfig { + Icon: React.ElementType; + className: string; + text: string; +} + +const BATTERY_STATUS = { + PLUGGED_IN: "PLUGGED_IN", + FULL: "FULL", + MEDIUM: "MEDIUM", + LOW: "LOW", +} as const; -const getBatteryState = ( - level: number, - batteryStates: BatteryStateConfig[], -) => { - return batteryStates.find((state) => state.condition(level)); +export const getBatteryStatus = (level: number): BatteryStatusKey => { + if (level > 100) { + return BATTERY_STATUS.PLUGGED_IN; + } + if (level > 80) { + return BATTERY_STATUS.FULL; + } + if (level > 20) { + return BATTERY_STATUS.MEDIUM; + } + return BATTERY_STATUS.LOW; }; const BatteryStatus: React.FC = ({ deviceMetrics }) => { + const { t } = useTranslation(); + if ( deviceMetrics?.batteryLevel === undefined || deviceMetrics?.batteryLevel === null @@ -65,16 +54,39 @@ const BatteryStatus: React.FC = ({ deviceMetrics }) => { return null; } - const { t } = useTranslation(); - const batteryStates = getBatteryStates(t); - const { batteryLevel } = deviceMetrics; - const currentState = getBatteryState(batteryLevel, batteryStates) ?? - batteryStates[batteryStates.length - 1]; - const BatteryIcon = currentState.Icon; - const iconClassName = currentState.className; - const statusText = currentState.text(batteryLevel); + const statusKey = getBatteryStatus(batteryLevel); + + const statusConfigMap: Record = { + [BATTERY_STATUS.PLUGGED_IN]: { + Icon: PlugZapIcon, + className: "text-gray-500", + text: t("batteryStatus.pluggedIn"), + }, + [BATTERY_STATUS.FULL]: { + Icon: BatteryFullIcon, + className: "text-green-500", + text: t("batteryStatus.charging", { level: batteryLevel }), + }, + [BATTERY_STATUS.MEDIUM]: { + Icon: BatteryMediumIcon, + className: "text-yellow-500", + text: t("batteryStatus.charging", { level: batteryLevel }), + }, + [BATTERY_STATUS.LOW]: { + Icon: BatteryLowIcon, + className: "text-red-500", + text: t("batteryStatus.charging", { level: batteryLevel }), + }, + }; + + // 3. Use the key to get the current state configuration + const { + Icon: BatteryIcon, + className: iconClassName, + text: statusText, + } = statusConfigMap[statusKey]; return (
{ connection?.setOwner( create(Protobuf.Mesh.UserSchema, { - ...(myNode?.user ?? {}), ...data, }), ); diff --git a/src/components/Dialog/ImportDialog.tsx b/src/components/Dialog/ImportDialog.tsx index ac76d022..6b448ace 100644 --- a/src/components/Dialog/ImportDialog.tsx +++ b/src/components/Dialog/ImportDialog.tsx @@ -72,17 +72,19 @@ export const ImportDialog = ({ }, [importDialogInput]); const apply = () => { - channelSet?.settings.map((ch: unknown, index: number) => { - connection?.setChannel( - create(Protobuf.Channel.ChannelSchema, { - index, - role: index === 0 - ? Protobuf.Channel.Channel_Role.PRIMARY - : Protobuf.Channel.Channel_Role.SECONDARY, - settings: ch, - }), - ); - }); + channelSet?.settings.map( + (ch: Protobuf.Channel.ChannelSettings, index: number) => { + connection?.setChannel( + create(Protobuf.Channel.ChannelSchema, { + index, + role: index === 0 + ? Protobuf.Channel.Channel_Role.PRIMARY + : Protobuf.Channel.Channel_Role.SECONDARY, + settings: ch, + }), + ); + }, + ); if (channelSet?.loraConfig) { connection?.setConfig( diff --git a/src/components/Dialog/LocationResponseDialog.tsx b/src/components/Dialog/LocationResponseDialog.tsx index bd376de3..eb6d5e25 100644 --- a/src/components/Dialog/LocationResponseDialog.tsx +++ b/src/components/Dialog/LocationResponseDialog.tsx @@ -12,7 +12,7 @@ import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; import { useTranslation } from "react-i18next"; export interface LocationResponseDialogProps { - location: Types.PacketMetadata | undefined; + location: Types.PacketMetadata | undefined; open: boolean; onOpenChange: () => void; } @@ -33,6 +33,13 @@ export const LocationResponseDialog = ({ ? `${numberToHexUnpadded(from?.num).substring(0, 4)}` : t("unknown.shortName")); + const position = location?.data; + + const hasCoordinates = position && + typeof position.latitudeI === "number" && + typeof position.longitudeI === "number" && + typeof position.altitude === "number"; + return ( @@ -45,31 +52,40 @@ export const LocationResponseDialog = ({ -
- -

- {t("locationResponse.coordinates")} - - {location?.data.latitudeI / 1e7},{" "} - {location?.data.longitudeI / 1e7} - -

-

- {t("locationResponse.altitude")} - {location?.data.altitude} - {location?.data.altitde < 1 - ? t("unit.meter.one") - : t("unit.meter.plural")} + {hasCoordinates + ? ( +

+ +

+ {t("locationResponse.coordinates")} + + {" "} + {position.latitudeI ?? 0 / 1e7},{" "} + {position.longitudeI ?? 0 / 1e7} + +

+

+ {t("locationResponse.altitude")} {position.altitude} + {(position.altitude ?? 0) < 1 + ? t("unit.meter.one") + : t("unit.meter.plural")} +

+
+
+ ) + : ( + // Optional: Show a message if coordinates are not available +

+ {t("locationResponse.noCoordinates")}

-
-
+ )}
diff --git a/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.test.tsx b/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.test.tsx deleted file mode 100644 index 0ba79fd1..00000000 --- a/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.test.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; -import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx"; -import { useDevice } from "@core/stores/deviceStore.ts"; -import { useAppStore } from "@core/stores/appStore.ts"; -import type { Protobuf } from "@meshtastic/core"; - -vi.mock("@core/stores/deviceStore"); -vi.mock("@core/stores/appStore"); - -const mockUseDevice = vi.mocked(useDevice); -const mockUseAppStore = vi.mocked(useAppStore); - -vi.mock("@tanstack/react-router", () => ({ - useNavigate: vi.fn(), -})); - -describe("NodeDetailsDialog", () => { - const mockNode = { - num: 1234, - user: { - longName: "Test Node", - shortName: "TN", - hwModel: 1, - role: 1, - }, - lastHeard: 1697500000, - position: { - latitudeI: 450000000, - longitudeI: -750000000, - altitude: 200, - }, - deviceMetrics: { - airUtilTx: 50.123, - channelUtilization: 75.456, - batteryLevel: 88.789, - voltage: 4.2, - uptimeSeconds: 3600, - }, - } as unknown as Protobuf.Mesh.NodeInfo; - - beforeEach(() => { - vi.resetAllMocks(); - - mockUseDevice.mockReturnValue({ - getNode: (nodeNum: number) => { - if (nodeNum === 1234) { - return mockNode; - } - return undefined; - }, - }); - - mockUseAppStore.mockReturnValue({ - nodeNumDetails: 1234, - }); - }); - - it("renders node details correctly", () => { - render( {}} />); - - expect(screen.getByText(/Node Details for Test Node \(TN\)/i)) - .toBeInTheDocument(); - - expect(screen.getByText("Node Number: 1234")).toBeInTheDocument(); - expect(screen.getByText(/Node Hex: !/i)).toBeInTheDocument(); - expect(screen.getByText(/Last Heard:/i)).toBeInTheDocument(); - - expect(screen.getByText(/Coordinates:/i)).toBeInTheDocument(); - const link = screen.getByRole("link", { name: /^45, -75$/ }); - - expect(link).toBeInTheDocument(); - expect(link).toHaveAttribute( - "href", - expect.stringContaining("openstreetmap.org"), - ); - expect(screen.getByText(/Altitude: 200m/i)).toBeInTheDocument(); - - expect(screen.getByText(/Air TX utilization: 50.12%/i)).toBeInTheDocument(); - expect(screen.getByText(/Channel utilization: 75.46%/i)) - .toBeInTheDocument(); - expect(screen.getByText(/Battery level: 88.79%/i)).toBeInTheDocument(); - expect(screen.getByText(/Voltage: 4.20V/i)).toBeInTheDocument(); - expect(screen.getByText(/Uptime:/i)).toBeInTheDocument(); - - expect(screen.getByText(/All Raw Metrics:/i)).toBeInTheDocument(); - }); - - it("renders null if node is undefined", () => { - const requestedNodeNum = 5678; - - mockUseAppStore.mockReturnValue({ - nodeNumDetails: requestedNodeNum, - }); - - mockUseDevice.mockReturnValue({ - getNode: (nodeNum: number) => { - if (nodeNum === requestedNodeNum) { - return undefined; - } - if (nodeNum === 1234) { - return mockNode; - } - return undefined; - }, - }); - - const { container } = render( - {}} />, - ); - - expect(container.firstChild).toBeNull(); - expect(screen.queryByText(/Node Details for/i)).not.toBeInTheDocument(); - }); - - it("renders correctly when position is missing", () => { - const nodeWithoutPosition = { ...mockNode, position: undefined }; - mockUseDevice.mockReturnValue({ getNode: () => nodeWithoutPosition }); - mockUseAppStore.mockReturnValue({ nodeNumDetails: 1234 }); - - render( {}} />); - - expect(screen.queryByText(/Coordinates:/i)).not.toBeInTheDocument(); - expect(screen.queryByText(/Altitude:/i)).not.toBeInTheDocument(); - expect(screen.getByText(/Node Details for Test Node/i)).toBeInTheDocument(); - }); - - it("renders correctly when deviceMetrics are missing", () => { - const nodeWithoutMetrics = { ...mockNode, deviceMetrics: undefined }; - mockUseDevice.mockReturnValue({ getNode: () => nodeWithoutMetrics }); - mockUseAppStore.mockReturnValue({ nodeNumDetails: 1234 }); - - render( {}} />); - - expect(screen.queryByText(/Device Metrics:/i)).not.toBeInTheDocument(); - expect(screen.queryByText(/Air TX utilization:/i)).not.toBeInTheDocument(); - expect(screen.getByText(/Node Details for Test Node/i)).toBeInTheDocument(); - }); - - it("renders 'Never' for lastHeard when timestamp is 0", () => { - const nodeNeverHeard = { ...mockNode, lastHeard: 0 }; - mockUseDevice.mockReturnValue({ getNode: () => nodeNeverHeard }); - mockUseAppStore.mockReturnValue({ nodeNumDetails: 1234 }); - - render( {}} />); - - expect(screen.getByText(/Last Heard: Never/i)).toBeInTheDocument(); - }); -}); diff --git a/src/components/Dialog/QRDialog.tsx b/src/components/Dialog/QRDialog.tsx index ae3d259f..0cb914e0 100644 --- a/src/components/Dialog/QRDialog.tsx +++ b/src/components/Dialog/QRDialog.tsx @@ -13,7 +13,6 @@ import { Input } from "@components/UI/Input.tsx"; import { Label } from "@components/UI/Label.tsx"; import { Protobuf, type Types } from "@meshtastic/core"; import { fromByteArray } from "base64-js"; -import { ClipboardIcon } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { QRCode } from "react-qrcode-logo"; import { useTranslation } from "react-i18next"; @@ -92,7 +91,7 @@ export const QRDialog = ({ { + onChange={() => { if (selectedChannels.includes(channel.index)) { setSelectedChannels( selectedChannels.filter((c) => @@ -144,13 +143,6 @@ export const QRDialog = ({ diff --git a/src/components/Dialog/RebootDialog.tsx b/src/components/Dialog/RebootDialog.tsx index 9b59baa2..7ed48ce6 100644 --- a/src/components/Dialog/RebootDialog.tsx +++ b/src/components/Dialog/RebootDialog.tsx @@ -9,7 +9,7 @@ import { } from "@components/UI/Dialog.tsx"; import { Input } from "@components/UI/Input.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; -import { ClockIcon, RefreshCwIcon } from "lucide-react"; +import { RefreshCwIcon } from "lucide-react"; import { useTranslation } from "react-i18next"; import { useState } from "react"; @@ -45,12 +45,6 @@ export const RebootDialog = ({ className="dark:text-slate-900" value={time} onChange={(e) => setTime(Number.parseInt(e.target.value))} - action={{ - icon: ClockIcon, - onClick() { - connection?.reboot(time * 60).then(() => onOpenChange(false)); - }, - }} /> -
- ); - }), -})); - -describe("Device component", () => { - const setWorkingConfigMock = vi.fn(); - const validateRoleSelectionMock = vi.fn(); - const mockDeviceConfig = { - role: "CLIENT", - buttonGpio: 0, - buzzerGpio: 0, - rebroadcastMode: "ALL", - nodeInfoBroadcastSecs: 300, - doubleTapAsButtonPress: false, - disableTripleClick: false, - ledHeartbeatDisabled: false, - }; - - beforeEach(() => { - vi.resetAllMocks(); - - // Mock the useDevice hook - useDevice.mockReturnValue({ - config: { - device: mockDeviceConfig, - }, - setWorkingConfig: setWorkingConfigMock, - }); - - // Mock the useUnsafeRolesDialog hook - validateRoleSelectionMock.mockResolvedValue(true); - useUnsafeRolesDialog.mockReturnValue({ - validateRoleSelection: validateRoleSelectionMock, - }); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - it("should render the Device form", () => { - render(); - expect(screen.getByTestId("dynamic-form")).toBeInTheDocument(); - }); - - it("should use the validateRoleSelection from the unsafe roles hook", () => { - render(); - expect(useUnsafeRolesDialog).toHaveBeenCalled(); - }); - - it("should call setWorkingConfig when form is submitted", async () => { - render(); - - fireEvent.click(screen.getByTestId("submit-button")); - - await waitFor(() => { - expect(setWorkingConfigMock).toHaveBeenCalledWith( - expect.objectContaining({ - payloadVariant: { - case: "device", - value: expect.objectContaining({ role: "CLIENT" }), - }, - }), - ); - }); - }); - - it("should create config with proper structure", async () => { - render(); - - // Simulate form submission - fireEvent.click(screen.getByTestId("submit-button")); - - await waitFor(() => { - expect(setWorkingConfigMock).toHaveBeenCalledWith( - expect.objectContaining({ - payloadVariant: { - case: "device", - value: expect.any(Object), - }, - }), - ); - }); - }); -}); diff --git a/src/components/PageComponents/Config/Network/Network.test.tsx b/src/components/PageComponents/Config/Network/Network.test.tsx deleted file mode 100644 index 8eacb277..00000000 --- a/src/components/PageComponents/Config/Network/Network.test.tsx +++ /dev/null @@ -1,173 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { Network } from "@components/PageComponents/Config/Network/index.tsx"; -import { useDevice } from "@core/stores/deviceStore.ts"; -import { Protobuf } from "@meshtastic/core"; - -vi.mock("@core/stores/deviceStore", () => ({ - useDevice: vi.fn(), -})); - -vi.mock("@components/Form/DynamicForm", async () => { - const React = await import("react"); - const { useState } = React; - - return { - DynamicForm: ({ onSubmit, defaultValues }) => { - const [wifiEnabled, setWifiEnabled] = useState( - defaultValues.wifiEnabled ?? false, - ); - const [ssid, setSsid] = useState(defaultValues.wifiSsid ?? ""); - const [psk, setPsk] = useState(defaultValues.wifiPsk ?? ""); - - return ( -
{ - e.preventDefault(); - onSubmit({ - ...defaultValues, - wifiEnabled, - wifiSsid: ssid, - wifiPsk: psk, - }); - }} - data-testid="dynamic-form" - > - setWifiEnabled(e.target.checked)} - /> - setSsid(e.target.value)} - disabled={!wifiEnabled} - /> - setPsk(e.target.value)} - disabled={!wifiEnabled} - /> - -
- ); - }, - }; -}); - -describe("Network component", () => { - const setWorkingConfigMock = vi.fn(); - const mockNetworkConfig = { - wifiEnabled: false, - wifiSsid: "", - wifiPsk: "", - ntpServer: "", - ethEnabled: false, - addressMode: Protobuf.Config.Config_NetworkConfig_AddressMode.DHCP, - ipv4Config: { - ip: 0, - gateway: 0, - subnet: 0, - dns: 0, - }, - enabledProtocols: - Protobuf.Config.Config_NetworkConfig_ProtocolFlags.NO_BROADCAST, - rsyslogServer: "", - }; - - beforeEach(() => { - vi.resetAllMocks(); - - useDevice.mockReturnValue({ - config: { - network: mockNetworkConfig, - }, - setWorkingConfig: setWorkingConfigMock, - }); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - it("should render the Network form", () => { - render(); - expect(screen.getByTestId("dynamic-form")).toBeInTheDocument(); - }); - - it("should disable SSID and PSK fields when wifi is off", () => { - render(); - expect(screen.getByLabelText("SSID")).toBeDisabled(); - expect(screen.getByLabelText("PSK")).toBeDisabled(); - }); - - it("should enable SSID and PSK when wifi is toggled on", async () => { - render(); - const toggle = screen.getByLabelText("WiFi Enabled"); - fireEvent.click(toggle); // turns wifiEnabled = true - - await waitFor(() => { - expect(screen.getByLabelText("SSID")).not.toBeDisabled(); - expect(screen.getByLabelText("PSK")).not.toBeDisabled(); - }); - }); - - it("should call setWorkingConfig with the right structure on submit", async () => { - render(); - - fireEvent.click(screen.getByTestId("submit-button")); - - await waitFor(() => { - expect(setWorkingConfigMock).toHaveBeenCalledWith( - expect.objectContaining({ - payloadVariant: { - case: "network", - value: expect.objectContaining({ - wifiEnabled: false, - wifiSsid: "", - wifiPsk: "", - ntpServer: "", - ethEnabled: false, - rsyslogServer: "", - }), - }, - }), - ); - }); - }); - - it("should submit valid data after enabling wifi and entering SSID and PSK", async () => { - render(); - fireEvent.click(screen.getByLabelText("WiFi Enabled")); - - fireEvent.change(screen.getByLabelText("SSID"), { - target: { value: "MySSID" }, - }); - - fireEvent.change(screen.getByLabelText("PSK"), { - target: { value: "MySecretPSK" }, - }); - - fireEvent.click(screen.getByTestId("submit-button")); - - await waitFor(() => { - expect(setWorkingConfigMock).toHaveBeenCalledWith( - expect.objectContaining({ - payloadVariant: { - case: "network", - value: expect.objectContaining({ - wifiEnabled: true, - wifiSsid: "MySSID", - wifiPsk: "MySecretPSK", - }), - }, - }), - ); - }); - }); -}); diff --git a/src/components/PageComponents/Connect/BLE.tsx b/src/components/PageComponents/Connect/BLE.tsx index a3458792..28aec612 100644 --- a/src/components/PageComponents/Connect/BLE.tsx +++ b/src/components/PageComponents/Connect/BLE.tsx @@ -7,6 +7,7 @@ import { subscribeAll } from "@core/subscriptions.ts"; import { randId } from "@core/utils/randId.ts"; import { TransportWebBluetooth } from "@meshtastic/transport-web-bluetooth"; import { MeshDevice } from "@meshtastic/core"; +import type { BluetoothDevice } from "web-bluetooth"; import { useCallback, useEffect, useState } from "react"; import { useMessageStore } from "@core/stores/messageStore/index.ts"; import { useTranslation } from "react-i18next"; @@ -77,7 +78,7 @@ export const BLE = ( if (exists === -1) { setBleDevices(bleDevices.concat(device)); } - }).catch((error) => { + }).catch((error: Error) => { console.error("Error requesting device:", error); setConnectionInProgress(false); }).finally(() => { diff --git a/src/components/PageComponents/Connect/HTTP.tsx b/src/components/PageComponents/Connect/HTTP.tsx index e1bca177..d92733af 100644 --- a/src/components/PageComponents/Connect/HTTP.tsx +++ b/src/components/PageComponents/Connect/HTTP.tsx @@ -66,7 +66,9 @@ export const HTTP = ( subscribeAll(device, connection, messageStore); closeDialog(); } catch (error) { - console.error("Connection error:", error); + if (error instanceof Error) { + console.error("Connection error:", error); + } // Capture all connection errors regardless of type setConnectionError({ host: data.ip, secure: data.tls }); setConnectionInProgress(false); diff --git a/src/components/PageComponents/Connect/Serial.tsx b/src/components/PageComponents/Connect/Serial.tsx index ff92c1e1..1303a016 100644 --- a/src/components/PageComponents/Connect/Serial.tsx +++ b/src/components/PageComponents/Connect/Serial.tsx @@ -9,7 +9,8 @@ import { MeshDevice } from "@meshtastic/core"; import { TransportWebSerial } from "@meshtastic/transport-web-serial"; import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { useMessageStore } from "../../../core/stores/messageStore/index.ts"; +import type { SerialPort } from "w3c-web-serial"; +import { useMessageStore } from "@core/stores/messageStore/index.ts"; export const Serial = ( { closeDialog }: TabElementProps, @@ -22,13 +23,13 @@ export const Serial = ( const { t } = useTranslation(); const updateSerialPortList = useCallback(async () => { - setSerialPorts(await navigator?.serial.getPorts()); + setSerialPorts(await navigator.serial.getPorts()); }, []); - navigator?.serial?.addEventListener("connect", () => { + navigator.serial.addEventListener("connect", () => { updateSerialPortList(); }); - navigator?.serial?.addEventListener("disconnect", () => { + navigator.serial.addEventListener("disconnect", () => { updateSerialPortList(); }); useEffect(() => { @@ -89,7 +90,7 @@ export const Serial = ( await navigator.serial.requestPort().then((port) => { setSerialPorts(serialPorts.concat(port)); // No need to setConnectionInProgress(false) here if requestPort is quick - }).catch((error) => { + }).catch((error: Error) => { console.error("Error requesting port:", error); }).finally(() => { setConnectionInProgress(false); diff --git a/src/components/PageComponents/Messages/MessageItem.tsx b/src/components/PageComponents/Messages/MessageItem.tsx index 7a932a1d..ab509741 100644 --- a/src/components/PageComponents/Messages/MessageItem.tsx +++ b/src/components/PageComponents/Messages/MessageItem.tsx @@ -56,21 +56,21 @@ export const MessageItem = ({ message }: MessageItemProps) => { const MESSAGE_STATUS_MAP = useMemo( (): Record => ({ [MessageState.Ack]: { - displayText: t("message_item_status_delivered_displayText"), + displayText: t("deliveryStatus.deliveryStatus."), icon: CheckCircle2, - ariaLabel: t("message_item_status_delivered_ariaLabel"), + ariaLabel: t("deliveryStatus.delivered"), iconClassName: "text-green-500", }, [MessageState.Waiting]: { - displayText: t("message_item_status_waiting_displayText"), + displayText: t("deliveryStatus.waiting"), icon: CircleEllipsis, - ariaLabel: t("message_item_status_waiting_ariaLabel"), + ariaLabel: t("deliveryStatus.waiting"), iconClassName: "text-slate-400", }, [MessageState.Failed]: { - displayText: t("message_item_status_failed_displayText"), + displayText: t("deliveryStatus.failed"), icon: AlertCircle, - ariaLabel: t("message_item_status_failed_ariaLabel"), + ariaLabel: t("deliveryStatus.failed"), iconClassName: "text-red-500 dark:text-red-400", }, }), @@ -78,9 +78,9 @@ export const MessageItem = ({ message }: MessageItemProps) => { ); const UNKNOWN_STATUS = useMemo((): MessageStatusInfo => ({ - displayText: t("message_item_status_unknown_displayText"), + displayText: t("delveryStatus.unknown"), icon: AlertCircle, - ariaLabel: t("message_item_status_unknown_ariaLabel"), + ariaLabel: t("deliveryStatus.unknown"), iconClassName: "text-red-500 dark:text-red-400", }), [t]); diff --git a/src/components/PageComponents/Messages/TraceRoute.test.tsx b/src/components/PageComponents/Messages/TraceRoute.test.tsx index cfd9dc6a..9a0f7298 100644 --- a/src/components/PageComponents/Messages/TraceRoute.test.tsx +++ b/src/components/PageComponents/Messages/TraceRoute.test.tsx @@ -2,29 +2,71 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { render, screen } from "@testing-library/react"; import { TraceRoute } from "@components/PageComponents/Messages/TraceRoute.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; -import type { Protobuf } from "@meshtastic/core"; +import { mockDeviceStore } from "@core/stores/deviceStore.mock.ts"; +import { Protobuf } from "@meshtastic/core"; vi.mock("@core/stores/deviceStore"); describe("TraceRoute", () => { + const fromUser = { + user: { + $typeName: "meshtastic.User", + longName: "Source Node", + publicKey: new Uint8Array([1, 2, 3]), + shortName: "Source", + hwModel: 1, + macaddr: new Uint8Array([0x01, 0x02, 0x03, 0x04]), + id: "source-node", + isLicensed: false, + role: Protobuf.Config.Config_DeviceConfig_Role["CLIENT"], + } as Protobuf.Mesh.NodeInfo["user"], + }; + + const toUser = { + user: { + $typeName: "meshtastic.User", + longName: "Destination Node", + publicKey: new Uint8Array([4, 5, 6]), + shortName: "Destination", + hwModel: 2, + macaddr: new Uint8Array([0x05, 0x06, 0x07, 0x08]), + id: "destination-node", + isLicensed: false, + role: Protobuf.Config.Config_DeviceConfig_Role["CLIENT"], + } as Protobuf.Mesh.NodeInfo["user"], + }; + const mockNodes = new Map([ [ 1, - { num: 1, user: { longName: "Node A" } } as Protobuf.Mesh.NodeInfo, + { + num: 1, + user: { longName: "Node A", $typeName: "meshtastic.User" }, + $typeName: "meshtastic.NodeInfo", + } as Protobuf.Mesh.NodeInfo, ], [ 2, - { num: 2, user: { longName: "Node B" } } as Protobuf.Mesh.NodeInfo, + { + num: 2, + user: { longName: "Node B", $typeName: "meshtastic.User" }, + $typeName: "meshtastic.NodeInfo", + } as Protobuf.Mesh.NodeInfo, ], [ 3, - { num: 3, user: { longName: "Node C" } } as Protobuf.Mesh.NodeInfo, + { + num: 3, + user: { longName: "Node C", $typeName: "meshtastic.User" }, + $typeName: "meshtastic.NodeInfo", + } as Protobuf.Mesh.NodeInfo, ], ]); beforeEach(() => { vi.resetAllMocks(); vi.mocked(useDevice).mockReturnValue({ + ...mockDeviceStore, getNode: (nodeNum: number): Protobuf.Mesh.NodeInfo | undefined => { return mockNodes.get(nodeNum); }, @@ -34,16 +76,15 @@ describe("TraceRoute", () => { it("renders the route to destination with SNR values", () => { render( , ); - expect(screen.getAllByText("Source Node")).toHaveLength(1); + expect(screen.getByText("Source Node")).toBeInTheDocument(); expect(screen.getByText("Destination Node")).toBeInTheDocument(); - expect(screen.getByText("Node A")).toBeInTheDocument(); expect(screen.getByText("Node B")).toBeInTheDocument(); @@ -56,8 +97,8 @@ describe("TraceRoute", () => { it("renders the route back when provided", () => { render( { />, ); + // Check for the translated title expect(screen.getByText("Route back:")).toBeInTheDocument(); + // With route back, both names appear twice expect(screen.getAllByText("Source Node")).toHaveLength(2); - expect(screen.getAllByText("Destination Node")).toHaveLength(2); - expect(screen.getByText("Node C")).toBeInTheDocument(); expect(screen.getByText("Node A")).toBeInTheDocument(); - - expect(screen.getByText("↓ 35dBm")).toBeInTheDocument(); - expect(screen.getByText("↓ 45dBm")).toBeInTheDocument(); + expect(screen.getByText("Node C")).toBeInTheDocument(); expect(screen.getByText("↓ 15dBm")).toBeInTheDocument(); expect(screen.getByText("↓ 25dBm")).toBeInTheDocument(); + expect(screen.getByText("↓ 35dBm")).toBeInTheDocument(); + expect(screen.getByText("↓ 45dBm")).toBeInTheDocument(); }); it("renders '??' for missing SNR values", () => { render( , ); expect(screen.getByText("Node A")).toBeInTheDocument(); - expect(screen.getAllByText("↓ ??dBm")).toHaveLength(2); - }); - - it("renders hop hex if node is not found", () => { - render( - , - ); - - expect(screen.getByText("↓ 5dBm")).toBeInTheDocument(); - expect(screen.getByText("↓ 15dBm")).toBeInTheDocument(); + // Check for translated '??' placeholder + expect(screen.getAllByText(/↓ \?\?dBm/)).toHaveLength(2); }); }); diff --git a/src/components/PageComponents/Messages/TraceRoute.tsx b/src/components/PageComponents/Messages/TraceRoute.tsx index 6ad25952..a1d85aa6 100644 --- a/src/components/PageComponents/Messages/TraceRoute.tsx +++ b/src/components/PageComponents/Messages/TraceRoute.tsx @@ -3,9 +3,11 @@ import type { Protobuf } from "@meshtastic/core"; import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; import { useTranslation } from "react-i18next"; +type NodeUser = Pick; + export interface TraceRouteProps { - from?: Protobuf.Mesh.NodeInfo; - to?: Protobuf.Mesh.NodeInfo; + from: NodeUser; + to: NodeUser; route: Array; routeBack?: Array; snrTowards?: Array; @@ -14,14 +16,14 @@ export interface TraceRouteProps { interface RoutePathProps { title: string; - startNode?: Protobuf.Mesh.NodeInfo; - endNode?: Protobuf.Mesh.NodeInfo; + from: NodeUser; + to: NodeUser; path: number[]; snr?: number[]; } const RoutePath = ( - { title, startNode, endNode, path, snr }: RoutePathProps, + { title, from, to, path, snr }: RoutePathProps, ) => { const { getNode } = useDevice(); const { t } = useTranslation(); @@ -32,7 +34,7 @@ const RoutePath = ( className="ml-4 border-l-2 pl-2 border-l-slate-900 text-slate-900 dark:text-slate-100 dark:border-l-slate-100" >

{title}

-

{startNode?.user?.longName}

+

{from?.user?.longName}

↓ {snr?.[0] ?? t("unknown.num")} {t("unit.dbm")} @@ -49,7 +51,7 @@ const RoutePath = (

))} -

{endNode?.user?.longName}

+

{to?.user?.longName}

); }; @@ -67,16 +69,16 @@ export const TraceRoute = ({
{routeBack && routeBack.length > 0 && ( diff --git a/src/components/UI/Button.tsx b/src/components/UI/Button.tsx index ef5ab101..905eca30 100644 --- a/src/components/UI/Button.tsx +++ b/src/components/UI/Button.tsx @@ -46,44 +46,36 @@ export interface ButtonProps iconAlignment?: "left" | "right"; } -const Button = React.forwardRef( - ( - { - className, - variant, - size, - disabled, - icon, - iconAlignment = "left", - children, - ...props - }, - ref, - ) => { - return ( - - ); - }, -); -Button.displayName = "Button"; +const Button = ({ + className, + variant, + size, + disabled, + icon, + iconAlignment = "left", + children, + ...props +}: ButtonProps) => { + return ( + + ); +}; export { Button, buttonVariants }; diff --git a/src/components/UI/Checkbox/Checkbox.test.tsx b/src/components/UI/Checkbox/Checkbox.test.tsx index 7e5ba73d..e7badabe 100644 --- a/src/components/UI/Checkbox/Checkbox.test.tsx +++ b/src/components/UI/Checkbox/Checkbox.test.tsx @@ -23,18 +23,6 @@ vi.mock("@components/UI/Label.tsx", () => ({ ), })); -vi.mock("@core/utils/cn.ts", () => ({ - cn: (...args) => args.filter(Boolean).join(" "), -})); - -vi.mock("react", async () => { - const actual = await vi.importActual("react"); - return { - ...actual, - useId: () => "test-id", - }; -}); - describe("Checkbox", () => { beforeEach(cleanup); @@ -67,11 +55,6 @@ describe("Checkbox", () => { expect(screen.getByRole("checkbox").id).toBe("custom-id"); }); - it("generates id when not provided", () => { - render(); - expect(screen.getByRole("checkbox").id).toBe("test-id"); - }); - it("renders children in Label component", () => { render(Test Label); expect(screen.getByTestId("label-component")).toHaveTextContent( diff --git a/src/components/UI/Dialog.tsx b/src/components/UI/Dialog.tsx index 763e53e0..8a820077 100644 --- a/src/components/UI/Dialog.tsx +++ b/src/components/UI/Dialog.tsx @@ -58,9 +58,7 @@ DialogContent.displayName = DialogPrimitive.Content.displayName; const DialogClose = ({ className, ...props -}: DialogPrimitive.DialogCloseProps & React.RefAttributes & { - className?: string; -}) => ( +}: React.ComponentPropsWithoutRef) => ( React.ReactNode); className?: string; } diff --git a/src/components/generic/Filter/useFilterNode.ts b/src/components/generic/Filter/useFilterNode.ts index da3b7347..a55fbe85 100644 --- a/src/components/generic/Filter/useFilterNode.ts +++ b/src/components/generic/Filter/useFilterNode.ts @@ -1,153 +1,185 @@ import { Protobuf } from "@meshtastic/core"; import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; -import { useMemo } from "react"; +import { useCallback, useMemo } from "react"; export type FilterState = { nodeName: string; hopsAway: [number, number]; lastHeard: [number, number]; - isFavorite: boolean | undefined; // undefined -> don't filter - viaMqtt: boolean | undefined; // undefined -> don't filter + isFavorite: boolean | undefined; + viaMqtt: boolean | undefined; snr: [number, number]; channelUtilization: [number, number]; airUtilTx: [number, number]; batteryLevel: [number, number]; voltage: [number, number]; - role: (Protobuf.Config.Config_DeviceConfig_Role)[]; - hwModel: (Protobuf.Mesh.HardwareModel)[]; + role: Protobuf.Config.Config_DeviceConfig_Role[]; + hwModel: Protobuf.Mesh.HardwareModel[]; }; -export function useFilterNode() { - const defaultFilterValues = useMemo(() => ({ - nodeName: "", - hopsAway: [0, 7], - lastHeard: [0, 864000], // 0-10 days - isFavorite: undefined, - viaMqtt: undefined, - snr: [-20, 10], - channelUtilization: [0, 100], - airUtilTx: [0, 100], - batteryLevel: [0, 101], - voltage: [0, 5], - role: Object.values(Protobuf.Config.Config_DeviceConfig_Role).filter( - (v): v is Protobuf.Config.Config_DeviceConfig_Role => - typeof v === "number", - ), - hwModel: Object.values(Protobuf.Mesh.HardwareModel).filter( - (v): v is Protobuf.Mesh.HardwareModel => typeof v === "number", - ), - }), []); - - function nodeFilter( - node: Protobuf.Mesh.NodeInfo, - filterOverrides?: Partial, - ): boolean { - const filterState: FilterState = { - ...defaultFilterValues, - ...filterOverrides, - }; - - if (!node.user) return false; - - const nodeName = filterState.nodeName.toLowerCase(); - if ( - !( - node.user?.shortName.toLowerCase().includes(nodeName) || - node.user?.longName.toLowerCase().includes(nodeName) || - node?.num.toString().includes(nodeName) || - numberToHexUnpadded(node?.num).includes( - nodeName.replace(/!/g, ""), - ) - ) - ) return false; - - const hops = node?.hopsAway ?? 7; - if (hops < filterState.hopsAway[0] || hops > filterState.hopsAway[1]) { +const shallowEqualArray = (a: T[], b: T[]): boolean => { + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) { return false; } - - const secondsAgo = Date.now() / 1000 - (node?.lastHeard ?? 0); - if ( - secondsAgo < filterState.lastHeard[0] || - ( - secondsAgo > filterState.lastHeard[1] && - filterState.lastHeard[1] !== defaultFilterValues.lastHeard[1] - ) - ) return false; - - if ( - typeof filterState.isFavorite !== "undefined" && - node.isFavorite !== filterState.isFavorite - ) return false; - - if ( - typeof filterState.viaMqtt !== "undefined" && - node.viaMqtt !== filterState.viaMqtt - ) return false; - - const snr = node?.snr ?? -20; - if ( - snr < filterState.snr[0] || - snr > filterState.snr[1] - ) return false; - - const channelUtilization = node?.deviceMetrics?.channelUtilization ?? 0; - if ( - channelUtilization < filterState.channelUtilization[0] || - channelUtilization > filterState.channelUtilization[1] - ) return false; - - const airUtilTx = node?.deviceMetrics?.airUtilTx ?? 0; - if ( - airUtilTx < filterState.airUtilTx[0] || - airUtilTx > filterState.airUtilTx[1] - ) return false; - - const batt = node?.deviceMetrics?.batteryLevel ?? 101; - if ( - batt < filterState.batteryLevel[0] || - batt > filterState.batteryLevel[1] - ) return false; - - const voltage = node?.deviceMetrics?.voltage ?? 0; - if ( - voltage < filterState.voltage[0] || - voltage > filterState.voltage[1] - ) return false; - - const role: Protobuf.Config.Config_DeviceConfig_Role = node.user?.role ?? - Protobuf.Config.Config_DeviceConfig_Role.CLIENT; - if (!filterState.role.includes(role)) return false; - - const hwModel: Protobuf.Mesh.HardwareModel = node.user?.hwModel ?? - Protobuf.Mesh.HardwareModel.UNSET; - if (!filterState.hwModel.includes(hwModel)) return false; - - // All conditions are true - return true; } + return true; +}; - // deno-lint-ignore no-explicit-any - function shallowEqualArray(a: any[], b: any[]) { - return a.length === b.length && a.every((v, i) => v === b[i]); - } +export function useFilterNode() { + const defaultFilterValues = useMemo( + () => ({ + nodeName: "", + hopsAway: [0, 7], + lastHeard: [0, 864000], // 0-10 days + isFavorite: undefined, + viaMqtt: undefined, + snr: [-20, 10], + channelUtilization: [0, 100], + airUtilTx: [0, 100], + batteryLevel: [0, 101], + voltage: [0, 5], + role: Object.values(Protobuf.Config.Config_DeviceConfig_Role).filter( + (v): v is Protobuf.Config.Config_DeviceConfig_Role => + typeof v === "number", + ), + hwModel: Object.values(Protobuf.Mesh.HardwareModel).filter( + (v): v is Protobuf.Mesh.HardwareModel => typeof v === "number", + ), + }), + [], + ); + + const nodeFilter = useCallback( + ( + node: Protobuf.Mesh.NodeInfo, + filterOverrides?: Partial, + ): boolean => { + const filterState: FilterState = { + ...defaultFilterValues, + ...filterOverrides, + }; + + if (!node.user) return false; + + const nodeName = filterState.nodeName.toLowerCase(); + if ( + nodeName && + !( + node.user?.shortName.toLowerCase().includes(nodeName) || + node.user?.longName.toLowerCase().includes(nodeName) || + node.num.toString().includes(nodeName) || + numberToHexUnpadded(node.num).includes(nodeName.replace(/!/g, "")) + ) + ) { + return false; + } + + const hops = node.hopsAway ?? 7; + if (hops < filterState.hopsAway[0] || hops > filterState.hopsAway[1]) { + return false; + } + + const secondsAgo = Date.now() / 1000 - (node.lastHeard ?? 0); + if ( + secondsAgo < filterState.lastHeard[0] || + (secondsAgo > filterState.lastHeard[1] && + filterState.lastHeard[1] !== defaultFilterValues.lastHeard[1]) + ) { + return false; + } + + if ( + typeof filterState.isFavorite !== "undefined" && + node.isFavorite !== filterState.isFavorite + ) { + return false; + } + + if ( + typeof filterState.viaMqtt !== "undefined" && + node.viaMqtt !== filterState.viaMqtt + ) { + return false; + } + + const snr = node.snr ?? -20; + if (snr < filterState.snr[0] || snr > filterState.snr[1]) return false; + + const channelUtilization = node.deviceMetrics?.channelUtilization ?? 0; + if ( + channelUtilization < filterState.channelUtilization[0] || + channelUtilization > filterState.channelUtilization[1] + ) { + return false; + } + + const airUtilTx = node.deviceMetrics?.airUtilTx ?? 0; + if ( + airUtilTx < filterState.airUtilTx[0] || + airUtilTx > filterState.airUtilTx[1] + ) { + return false; + } + + const batt = node.deviceMetrics?.batteryLevel ?? 101; + if ( + batt < filterState.batteryLevel[0] || + batt > filterState.batteryLevel[1] + ) { + return false; + } + + const voltage = node.deviceMetrics?.voltage ?? 0; + if ( + voltage < filterState.voltage[0] || + voltage > filterState.voltage[1] + ) { + return false; + } + + const role: Protobuf.Config.Config_DeviceConfig_Role = node.user.role ?? + Protobuf.Config.Config_DeviceConfig_Role.CLIENT; + if (!filterState.role.includes(role)) return false; + + const hwModel: Protobuf.Mesh.HardwareModel = node.user.hwModel ?? + Protobuf.Mesh.HardwareModel.UNSET; + if (!filterState.hwModel.includes(hwModel)) return false; + + return true; + }, + [defaultFilterValues], + ); + + const isFilterDirty = useCallback( + ( + current: FilterState, + overrides?: Partial, + ): boolean => { + const base: FilterState = overrides + ? { ...defaultFilterValues, ...overrides } + : defaultFilterValues; + + for (const key of Object.keys(base) as (keyof FilterState)[]) { + const currentValue = current[key]; + const defaultValue = base[key]; + + if (Array.isArray(defaultValue) && Array.isArray(currentValue)) { + if (!shallowEqualArray(currentValue, defaultValue)) { + return true; + } + } else if (currentValue !== defaultValue) { + return true; + } + } - function isFilterDirty( - current: FilterState, - overrides?: Partial, - ): boolean { - const base: FilterState = overrides - ? { ...defaultFilterValues, ...overrides } - : defaultFilterValues; - - return (Object.keys(base) as (keyof FilterState)[]).some((key) => { - const curr = current[key]; - const def = base[key]; - return Array.isArray(def) && Array.isArray(curr) - ? !shallowEqualArray(curr, def) - : curr !== def; - }); - } + return false; + }, + [defaultFilterValues], + ); return { nodeFilter, defaultFilterValues, isFilterDirty }; } diff --git a/src/components/generic/Table/index.test.tsx b/src/components/generic/Table/index.test.tsx index 727357f4..8062144b 100644 --- a/src/components/generic/Table/index.test.tsx +++ b/src/components/generic/Table/index.test.tsx @@ -1,142 +1,128 @@ import { describe, expect, it } from "vitest"; import { fireEvent, render, screen } from "@testing-library/react"; -import { Table } from "@components/generic/Table/index.tsx"; +import { DataRow, Heading, Table } from "@components/generic/Table/index.tsx"; import { TimeAgo } from "@components/generic/TimeAgo.tsx"; import { Mono } from "@components/generic/Mono.tsx"; // @ts-types="react" describe("Generic Table", () => { it("Can render an empty table.", () => { - render( - , - ); + render(
); expect(screen.getByRole("table")).toBeInTheDocument(); }); it("Can render a table with headers and no rows.", async () => { - render( -
, - ); + const headings: Heading[] = [ + { title: "Short Name", sortable: true }, + { title: "Last Heard", sortable: true }, + { title: "Connection", sortable: true }, + ]; + render(
); await screen.findByRole("table"); - expect(screen.getAllByRole("columnheader")).toHaveLength(9); + expect(screen.getAllByRole("columnheader")).toHaveLength(3); }); - // A simplified version of the rows in pages/Nodes.tsx for testing purposes - const mockDevicesWithShortNameAndConnection = [ + // Mock data representing devices + const mockDevices = [ { - user: { shortName: "TST1" }, + id: "TST1", + shortName: "TST1", hopsAway: 1, - lastHeard: Date.now() + 1000, + lastHeard: Date.now() - 3000, viaMqtt: false, }, { - user: { shortName: "TST2" }, + id: "TST2", + shortName: "TST2", hopsAway: 0, - lastHeard: Date.now() + 4000, + lastHeard: Date.now() - 1000, viaMqtt: true, + isFavorite: true, // Favorite device }, { - user: { shortName: "TST3" }, + id: "TST3", + shortName: "TST3", hopsAway: 4, - lastHeard: Date.now(), + lastHeard: Date.now() - 5000, viaMqtt: false, }, { - user: { shortName: "TST4" }, + id: "TST4", + shortName: "TST4", hopsAway: 3, - lastHeard: Date.now() + 2000, + lastHeard: Date.now() - 2000, viaMqtt: true, }, ]; - const mockRows = mockDevicesWithShortNameAndConnection.map((node) => [ -

{node.user.shortName}

, - - - , - - {node.lastHeard !== 0 - ? node.viaMqtt === false && node.hopsAway === 0 - ? "Direct" - : `${node.hopsAway?.toString()} ${ - node.hopsAway ?? 0 > 1 ? "hops" : "hop" - } away` - : "-"} - {node.viaMqtt === true ? ", via MQTT" : ""} - , - ]); + // Transform mock data into the format expected by the Table component + const mockRows: DataRow[] = mockDevices.map((node) => ({ + id: node.id, + isFavorite: node.isFavorite, + cells: [ + { + content: {node.shortName}, + sortValue: node.shortName, + }, + { + content: ( + + + + ), + sortValue: node.lastHeard, + }, + { + content: ( + + {node.lastHeard !== 0 + ? node.viaMqtt === false && node.hopsAway === 0 + ? "Direct" + : `${node.hopsAway} ${node.hopsAway > 1 ? "hops" : "hop"} away` + : "-"} + {node.viaMqtt ? ", via MQTT" : ""} + + ), + sortValue: node.hopsAway, + }, + ], + })); - it("Can sort rows appropriately.", async () => { - render( -
, - ); + const headings: Heading[] = [ + { title: "Short Name", sortable: true }, + { title: "Last Heard", sortable: true }, + { title: "Connection", sortable: true }, + ]; + + it("Can sort rows, keeping favorites at the top", async () => { + render(
); const renderedTable = await screen.findByRole("table"); const columnHeaders = screen.getAllByRole("columnheader"); expect(columnHeaders).toHaveLength(3); - // Will be sorted "Last heard" "asc" by default - expect( - [...renderedTable.querySelectorAll("[data-testshortname]")] - .map((el) => el.textContent) - .map((v) => v?.trim()) - .join(","), - ) - .toMatch("TST2,TST4,TST1,TST3"); - - fireEvent.click(columnHeaders[0]); + const getRenderedOrder = () => + [...renderedTable.querySelectorAll("[data-testid='short-name']")].map( + (el) => el.textContent?.trim(), + ); - // Re-sort by Short Name asc - expect( - [...renderedTable.querySelectorAll("[data-testshortname]")] - .map((el) => el.textContent) - .map((v) => v?.trim()) - .join(","), - ) - .toMatch("TST1,TST2,TST3,TST4"); + // Default sort: "Last Heard" desc. TST2 is favorite, so it's first. + // Then the rest are sorted by lastHeard timestamp (most recent first). + // Order of timestamps: TST2 (latest, but favorite), TST4, TST1, TST3 (oldest). + expect(getRenderedOrder()).toEqual(["TST2", "TST4", "TST1", "TST3"]); + // Click "Short Name" to sort asc fireEvent.click(columnHeaders[0]); + // TST2 is favorite, so it's first. Then TST1, TST3, TST4 alphabetically. + expect(getRenderedOrder()).toEqual(["TST2", "TST1", "TST3", "TST4"]); - // Re-sort by Short Name desc - expect( - [...renderedTable.querySelectorAll("[data-testshortname]")] - .map((el) => el.textContent) - .map((v) => v?.trim()) - .join(","), - ) - .toMatch("TST4,TST3,TST2,TST1"); + // Click "Short Name" again to sort desc + fireEvent.click(columnHeaders[0]); + // TST2 is favorite, so it's first. Then TST4, TST3, TST1 reverse alphabetically. + expect(getRenderedOrder()).toEqual(["TST2", "TST4", "TST3", "TST1"]); + // Click "Connection" to sort by hops asc fireEvent.click(columnHeaders[2]); - - // Re-sort by Hops Away - expect( - [...renderedTable.querySelectorAll("[data-testshortname]")] - .map((el) => el.textContent) - .map((v) => v?.trim()) - .join(","), - ) - .toMatch("TST2,TST1,TST4,TST3"); + // TST2 is favorite (and also has 0 hops). Then sorted by hops: TST1 (1), TST4 (3), TST3 (4). + expect(getRenderedOrder()).toEqual(["TST2", "TST1", "TST4", "TST3"]); }); }); diff --git a/src/components/generic/Table/index.tsx b/src/components/generic/Table/index.tsx index ae66a730..af699644 100755 --- a/src/components/generic/Table/index.tsx +++ b/src/components/generic/Table/index.tsx @@ -1,30 +1,32 @@ import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import React from "react"; import { cn } from "@core/utils/cn.ts"; -interface FavoriteIconProps { - showFavorite: boolean; +export interface Heading { + title: string; + sortable: boolean; } -interface AvatarCellProps { - children: React.ReactElement; +interface Cell { + content: React.ReactNode; + sortValue: string | number; } -export interface TableProps { - headings: Heading[]; - rows: React.ReactElement[][]; +export interface DataRow { + id: string | number; + isFavorite?: boolean; + cells: Cell[]; } -export interface Heading { - title: string; - type: "blank" | "normal"; - sortable: boolean; +export interface TableProps { + headings: Heading[]; + rows: DataRow[]; } -function numericHops(hopsAway: string | unknown): number { - if (typeof hopsAway !== "string") { - return Number.MAX_SAFE_INTEGER; +function numericHops(hopsAway: string | number): number { + if (typeof hopsAway === "number") { + return hopsAway; } if (hopsAway.match(/direct/i)) { return 0; @@ -37,7 +39,7 @@ export const Table = ({ headings, rows }: TableProps) => { const [sortColumn, setSortColumn] = useState("Last Heard"); const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc"); - const headingSort = (title: string) => { + const handleSort = (title: string) => { if (sortColumn === title) { setSortOrder(sortOrder === "asc" ? "desc" : "asc"); } else { @@ -46,72 +48,36 @@ export const Table = ({ headings, rows }: TableProps) => { } }; - const getElement = (cell: React.ReactNode): React.ReactElement | null => { - if (!React.isValidElement(cell)) { - return null; - } - if (cell.type === React.Fragment) { - const childrenArray = React.Children.toArray(cell.props.children); - const firstElement = childrenArray.find((child) => - React.isValidElement(child) - ); - return (firstElement as React.ReactElement) ?? null; - } - // If not a fragment, return the element itself - return cell; - }; - - const sortedRows = rows.slice().sort((a, b) => { - if (!sortColumn) return 0; + const sortedRows = useMemo(() => { + if (!sortColumn) return rows; const columnIndex = headings.findIndex((h) => h.title === sortColumn); - if (columnIndex === -1) return 0; + if (columnIndex === -1) return rows; - const elementA = getElement(a[columnIndex]); - const elementB = getElement(b[columnIndex]); + return [...rows].sort((a, b) => { + if (a.isFavorite !== b.isFavorite) { + return a.isFavorite ? -1 : 1; + } - // Avatar contains the prop showFavorite which indicates isFavorite - const favA = a[0]?.props?.children?.props?.showFavorite ?? false; - const favB = b[0]?.props?.children?.props?.showFavorite ?? false; + const aCell = a.cells[columnIndex]; + const bCell = b.cells[columnIndex]; - // Always put favorites at the top - if (favA !== favB) return favA ? -1 : 1; + let aValue: string | number; + let bValue: string | number; - if (sortColumn === "Last Heard") { - const aTimestamp = elementA?.props?.children?.props?.timestamp ?? 0; - const bTimestamp = elementB?.props?.children?.props?.timestamp ?? 0; - if (aTimestamp < bTimestamp) return sortOrder === "asc" ? -1 : 1; - if (aTimestamp > bTimestamp) return sortOrder === "asc" ? 1 : -1; - return 0; - } + if (sortColumn === "Connection") { + aValue = numericHops(aCell.sortValue); + bValue = numericHops(bCell.sortValue); + } else { + aValue = aCell.sortValue; + bValue = bCell.sortValue; + } - if (sortColumn === "Connection") { - const aHopsStr = elementA?.props?.children[0]; - const bHopsStr = elementB?.props?.children[0]; - const aNumHops = numericHops(aHopsStr); - const bNumHops = numericHops(bHopsStr); - if (aNumHops < bNumHops) return sortOrder === "asc" ? -1 : 1; - if (aNumHops > bNumHops) return sortOrder === "asc" ? 1 : -1; + if (aValue < bValue) return sortOrder === "asc" ? -1 : 1; + if (aValue > bValue) return sortOrder === "asc" ? 1 : -1; return 0; - } - - const aValue = elementA?.props?.children; - const bValue = elementB?.props?.children; - const valA = aValue ?? ""; - const valB = bValue ?? ""; - - // Ensure consistent comparison for potentially different types - const compareA = typeof valA === "string" || typeof valA === "number" - ? valA - : String(valA); - const compareB = typeof valB === "string" || typeof valB === "number" - ? valB - : String(valB); - - if (compareA < compareB) return sortOrder === "asc" ? -1 : 1; - if (compareA > compareB) return sortOrder === "asc" ? 1 : -1; - return 0; - }); + }); + }, [rows, sortColumn, sortOrder, headings]); return (
@@ -121,17 +87,15 @@ export const Table = ({ headings, rows }: TableProps) => { - {sortedRows.map((row) => { - const firstCellKey = - (React.isValidElement(row[0]) && row[0].key !== null) - ? String(row[0].key) - : null; - const rowKey = firstCellKey ?? Math.random().toString(); // Use random only as last resort - - const isFavorite = row[0]?.props?.children?.props?.showFavorite ?? - false; - return ( - - {row.map((item, cellIndex) => { - const cellKey = `${rowKey}_${cellIndex}`; - return cellIndex === 0 - ? ( - - ) - : ( - - ); - })} - - ); - })} + {sortedRows.map((row) => ( + + {row.cells.map((cell, cellIndex) => + cellIndex === 0 + ? ( + + ) + : ( + + ) + )} + + ))}
heading.sortable && headingSort(heading.title)} + className={cn( + "py-2 pr-3 text-left", + heading.sortable && + "cursor-pointer hover:brightness-hover active:brightness-press", + )} + onClick={() => heading.sortable && handleSort(heading.title)} onKeyUp={(e) => { - if ( - heading.sortable && (e.key === "Enter" || e.key === " ") - ) { - headingSort(heading.title); + if (heading.sortable && (e.key === "Enter" || e.key === " ")) { + handleSort(heading.title); } }} tabIndex={heading.sortable ? 0 : -1} @@ -153,49 +117,37 @@ export const Table = ({ headings, rows }: TableProps) => {
- {item} - - {item} -
+ {cell.content} + + {cell.content} +
); diff --git a/src/core/hooks/useBrowserFeatureDetection.ts b/src/core/hooks/useBrowserFeatureDetection.ts index 3f2667f2..e792ca1b 100644 --- a/src/core/hooks/useBrowserFeatureDetection.ts +++ b/src/core/hooks/useBrowserFeatureDetection.ts @@ -10,8 +10,8 @@ interface BrowserSupport { export function useBrowserFeatureDetection(): BrowserSupport { const support = useMemo(() => { const features: [BrowserFeature, boolean][] = [ - ["Web Bluetooth", !!navigator?.bluetooth], - ["Web Serial", !!navigator?.serial], + ["Web Bluetooth", !!navigator.bluetooth], + ["Web Serial", !!navigator.serial], [ "Secure Context", globalThis.location.protocol === "https:" || diff --git a/src/core/hooks/useCookie.ts b/src/core/hooks/useCookie.ts index df3d9d82..24e97b01 100644 --- a/src/core/hooks/useCookie.ts +++ b/src/core/hooks/useCookie.ts @@ -1,9 +1,9 @@ -import Cookies, { type CookieAttributes } from "js-cookie"; +import Cookies from "js-cookie"; import { useCallback, useState } from "react"; interface CookieHookResult { value: T | undefined; - setCookie: (value: T, options?: CookieAttributes) => void; + setCookie: (value: T, options?: Cookies.CookieAttributes) => void; removeCookie: () => void; } @@ -22,7 +22,7 @@ function useCookie( }); const setCookie = useCallback( - (value: T, options?: CookieAttributes) => { + (value: T, options?: Cookies.CookieAttributes) => { try { Cookies.set(cookieName, JSON.stringify(value), options); setCookieValue(value); diff --git a/src/core/hooks/usePinnedItems.test.ts b/src/core/hooks/usePinnedItems.test.ts deleted file mode 100644 index 3741ffdb..00000000 --- a/src/core/hooks/usePinnedItems.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { act, renderHook } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { usePinnedItems } from "./usePinnedItems.ts"; - -const mockSetPinnedItems = vi.fn(); -const mockUseLocalStorage = vi.fn(); - -vi.mock("@core/hooks/useLocalStorage.ts", () => ({ - default: (...args) => mockUseLocalStorage(...args), -})); - -describe("usePinnedItems", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("returns default pinnedItems and togglePinnedItem", () => { - mockUseLocalStorage.mockReturnValue([[], mockSetPinnedItems]); - - const { result } = renderHook(() => - usePinnedItems({ storageName: "test-storage" }) - ); - - expect(result.current.pinnedItems).toEqual([]); - expect(typeof result.current.togglePinnedItem).toBe("function"); - }); - - it("adds an item if it's not already pinned", () => { - mockUseLocalStorage.mockReturnValue([["item1"], mockSetPinnedItems]); - - const { result } = renderHook(() => - usePinnedItems({ storageName: "test-storage" }) - ); - - act(() => { - result.current.togglePinnedItem("item2"); - }); - - expect(mockSetPinnedItems).toHaveBeenCalledWith(expect.any(Function)); - - const updater = mockSetPinnedItems.mock.calls[0][0]; - const updated = updater(["item1"]); - - expect(updated).toEqual(["item1", "item2"]); - }); - - it("removes an item if it's already pinned", () => { - mockUseLocalStorage.mockReturnValue([ - ["item1", "item2"], - mockSetPinnedItems, - ]); - - const { result } = renderHook(() => - usePinnedItems({ storageName: "test-storage" }) - ); - - act(() => { - result.current.togglePinnedItem("item1"); - }); - - expect(mockSetPinnedItems).toHaveBeenCalledWith(expect.any(Function)); - - const updater = mockSetPinnedItems.mock.calls[0][0]; - const updated = updater(["item1", "item2"]); - - expect(updated).toEqual(["item2"]); - }); -}); diff --git a/src/core/stores/deviceStore.mock.ts b/src/core/stores/deviceStore.mock.ts new file mode 100644 index 00000000..fb97c9d3 --- /dev/null +++ b/src/core/stores/deviceStore.mock.ts @@ -0,0 +1,82 @@ +import { vi } from "vitest"; +import { type Device } from "./deviceStore.ts"; +import { Protobuf } from "@meshtastic/core"; + +/** + * You can spread this base mock in your tests and override only the + * properties relevant to a specific test case. + * + * @example + * vi.mocked(useDevice).mockReturnValue({ + * ...mockDeviceStore, + * getNode: (nodeNum) => mockNodes.get(nodeNum), + * }); + */ +export const mockDeviceStore: Device = { + id: 0, + status: 5 as const, + channels: new Map(), + config: {} as Protobuf.LocalOnly.LocalConfig, + moduleConfig: {} as Protobuf.LocalOnly.LocalModuleConfig, + workingConfig: [], + workingModuleConfig: [], + hardware: {} as Protobuf.Mesh.MyNodeInfo, + metadata: new Map(), + traceroutes: new Map(), + nodeErrors: new Map(), + connection: undefined, + activeNode: 0, + waypoints: [], + pendingSettingsChanges: false, + messageDraft: "", + unreadCounts: new Map(), + nodesMap: new Map(), + dialog: { + import: false, + QR: false, + shutdown: false, + reboot: false, + rebootOTA: false, + deviceName: false, + nodeRemoval: false, + pkiBackup: false, + nodeDetails: false, + unsafeRoles: false, + refreshKeys: false, + deleteMessages: false, + }, + setStatus: vi.fn(), + setConfig: vi.fn(), + setModuleConfig: vi.fn(), + setWorkingConfig: vi.fn(), + setWorkingModuleConfig: vi.fn(), + setHardware: vi.fn(), + setActiveNode: vi.fn(), + setPendingSettingsChanges: vi.fn(), + addChannel: vi.fn(), + addWaypoint: vi.fn(), + addNodeInfo: vi.fn(), + addUser: vi.fn(), + addPosition: vi.fn(), + addConnection: vi.fn(), + addTraceRoute: vi.fn(), + addMetadata: vi.fn(), + removeNode: vi.fn(), + setDialogOpen: vi.fn(), + getDialogOpen: vi.fn().mockReturnValue(false), + processPacket: vi.fn(), + setMessageDraft: vi.fn(), + setNodeError: vi.fn(), + clearNodeError: vi.fn(), + getNodeError: vi.fn().mockReturnValue(undefined), + hasNodeError: vi.fn().mockReturnValue(false), + incrementUnread: vi.fn(), + resetUnread: vi.fn(), + getNodes: vi.fn().mockReturnValue([]), + getNodesLength: vi.fn().mockReturnValue(0), + getNode: vi.fn().mockReturnValue(undefined), + getMyNode: vi.fn(), + sendAdminMessage: vi.fn(), + updateFavorite: vi.fn(), + updateIgnored: vi.fn(), +}; diff --git a/src/core/stores/deviceStore.ts b/src/core/stores/deviceStore.ts index d8b7b03d..488a5d5d 100644 --- a/src/core/stores/deviceStore.ts +++ b/src/core/stores/deviceStore.ts @@ -114,7 +114,7 @@ export const useDeviceStore = createStore((set, get) => ({ addDevice: (id: number) => { set( - produce((draft) => { + produce((draft) => { draft.devices.set(id, { id, status: Types.DeviceStatusEnum.DeviceDisconnected, @@ -151,7 +151,7 @@ export const useDeviceStore = createStore((set, get) => ({ setStatus: (status: Types.DeviceStatusEnum) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (device) { device.status = status; @@ -161,7 +161,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, setConfig: (config: Protobuf.Config.Config) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (device) { switch (config.payloadVariant.case) { @@ -203,7 +203,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, setModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (device) { switch (config.payloadVariant.case) { @@ -271,7 +271,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, setWorkingConfig: (config: Protobuf.Config.Config) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (!device) return; const index = device.workingConfig.findIndex( @@ -289,7 +289,7 @@ export const useDeviceStore = createStore((set, get) => ({ moduleConfig: Protobuf.ModuleConfig.ModuleConfig, ) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (!device) return; const index = device.workingModuleConfig.findIndex( @@ -307,7 +307,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, setHardware: (hardware: Protobuf.Mesh.MyNodeInfo) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (device) { device.hardware = hardware; @@ -317,7 +317,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, setPendingSettingsChanges: (state) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (device) { device.pendingSettingsChanges = state; @@ -327,7 +327,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, addChannel: (channel: Protobuf.Channel.Channel) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (device) { device.channels.set(channel.index, channel); @@ -337,7 +337,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, addWaypoint: (waypoint: Protobuf.Mesh.Waypoint) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (device) { const index = device.waypoints.findIndex((wp) => @@ -354,7 +354,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, addNodeInfo: (nodeInfo) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (!device) return; @@ -364,7 +364,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, setActiveNode: (node) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (device) { device.activeNode = node; @@ -374,7 +374,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, addUser: (user) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (!device) { return; @@ -389,7 +389,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, addPosition: (position) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (!device) { return; @@ -404,7 +404,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, addConnection: (connection) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (device) { device.connection = connection; @@ -414,7 +414,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, addMetadata: (from, metadata) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (device) { device.metadata.set(from, metadata); @@ -424,7 +424,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, addTraceRoute: (traceroute) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (!device) return; const routes = device.traceroutes.get(traceroute.from) ?? []; @@ -433,9 +433,9 @@ export const useDeviceStore = createStore((set, get) => ({ }), ); }, - removeNode: (nodeNum) => { + removeNode: (nodeNum: number) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (!device) { return; @@ -446,7 +446,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, setDialogOpen: (dialog: DialogVariant, open: boolean) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (device) { device.dialog[dialog] = open; @@ -461,7 +461,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, processPacket(data: ProcessPacketParams) { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (!device) return; const node = device.nodesMap.get(data.from); @@ -484,7 +484,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, setMessageDraft: (message: string) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (device) { device.messageDraft = message; @@ -492,9 +492,9 @@ export const useDeviceStore = createStore((set, get) => ({ }), ); }, - setNodeError: (nodeNum, error) => { + setNodeError: (nodeNum: number, error: string) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (device) { device.nodeErrors.set(nodeNum, { node: nodeNum, error }); @@ -504,7 +504,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, clearNodeError: (nodeNum: number) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (device) { device.nodeErrors.delete(nodeNum); @@ -524,7 +524,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, incrementUnread: (nodeNum: number) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (!device) return; const currentCount = device.unreadCounts.get(nodeNum) ?? 0; @@ -534,7 +534,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, resetUnread: (nodeNum: number) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (!device) return; device.unreadCounts.set(nodeNum, 0); @@ -610,10 +610,12 @@ export const useDeviceStore = createStore((set, get) => ({ })); set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); const node = device?.nodesMap.get(nodeNum); - node.isFavorite = isFavorite; + if (node) { + node.isFavorite = isFavorite; + } }), ); }, @@ -631,10 +633,12 @@ export const useDeviceStore = createStore((set, get) => ({ })); set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); const node = device?.nodesMap.get(nodeNum); - node.isIgnored = isIgnored; + if (node) { + node.isIgnored = isIgnored; + } }), ); }, @@ -651,7 +655,7 @@ export const useDeviceStore = createStore((set, get) => ({ removeDevice: (id) => { set( - produce((draft) => { + produce((draft) => { draft.devices.delete(id); }), ); diff --git a/src/core/stores/storage/indexDB.ts b/src/core/stores/storage/indexDB.ts index d7d89342..ce2c2790 100644 --- a/src/core/stores/storage/indexDB.ts +++ b/src/core/stores/storage/indexDB.ts @@ -1,4 +1,4 @@ -import { PersistStorage, StateStorage } from "zustand/middleware"; +import { PersistStorage, StateStorage, StorageValue } from "zustand/middleware"; import { del, get, set } from "idb-keyval"; import { ChannelId, MessageLogMap } from "@core/stores/messageStore/types.ts"; @@ -56,18 +56,25 @@ const reviver: JsonReviver = (_, value) => { }; export const storageWithMapSupport: PersistStorage = { - getItem: async (name): Promise => { + getItem: async ( + name, + ): Promise | null> => { const str = await zustandIndexDBStorage.getItem(name); if (!str) return null; try { - const parsed = JSON.parse(str, reviver) as PersistedMessageState; + const parsed = JSON.parse(str, reviver) as StorageValue< + PersistedMessageState + >; return parsed; } catch (error) { console.error(`Error parsing persisted state (${name}):`, error); return null; } }, - setItem: async (name, newValue: PersistedMessageState): Promise => { + setItem: async ( + name, + newValue: StorageValue, + ): Promise => { try { const str = JSON.stringify(newValue, replacer); await zustandIndexDBStorage.setItem(name, str); diff --git a/src/core/utils/eventBus.test.ts b/src/core/utils/eventBus.test.ts index 6d2fc6ee..2d874066 100644 --- a/src/core/utils/eventBus.test.ts +++ b/src/core/utils/eventBus.test.ts @@ -4,7 +4,7 @@ import { eventBus } from "@core/utils/eventBus.ts"; describe("EventBus", () => { beforeEach(() => { // Reset event listeners before each test - eventBus.listeners = {}; + eventBus.offAll(); }); it("should register an event listener and trigger it on emit", () => { diff --git a/src/core/utils/eventBus.ts b/src/core/utils/eventBus.ts index 618ab5a6..75c48d8c 100644 --- a/src/core/utils/eventBus.ts +++ b/src/core/utils/eventBus.ts @@ -34,6 +34,14 @@ class EventBus { } } + public offAll(event?: T): void { + if (event) { + this.listeners[event] = []; + } else { + this.listeners = {}; + } + } + public emit(event: T, data: EventMap[T]): void { if (!this.listeners[event]) return; diff --git a/src/core/utils/ip.test.ts b/src/core/utils/ip.test.ts index 2da62e57..6208e6d3 100644 --- a/src/core/utils/ip.test.ts +++ b/src/core/utils/ip.test.ts @@ -60,7 +60,7 @@ describe("IP Address Conversion Functions", () => { for (const ip of testIps) { const int = convertIpAddressToInt(ip); expect(int).not.toBeNull(); - if (int !== null) { + if (int !== null && typeof int === "number") { const convertedBack = convertIntToIpAddress(int); expect(convertedBack).toBe(ip); } diff --git a/src/core/utils/sort.ts b/src/core/utils/sort.ts new file mode 100644 index 00000000..2d1494f0 --- /dev/null +++ b/src/core/utils/sort.ts @@ -0,0 +1,18 @@ +export function intlSort( + arr: T[], + order: "asc" | "desc" = "asc", + locale: Intl.Locale, +): T[] { + const collator = new Intl.Collator(locale, { sensitivity: "base" }); + + return arr.sort((a, b) => { + const stringA = String(a); + const stringB = String(b); + + if (order === "asc") { + return collator.compare(stringA, stringB); + } else { + return collator.compare(stringB, stringA); + } + }); +} diff --git a/src/core/utils/test.tsx b/src/core/utils/test.tsx deleted file mode 100644 index cb9906ae..00000000 --- a/src/core/utils/test.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { - createMemoryHistory, - createRouter, - Outlet, - RootRoute, - Route, - RouterProvider, -} from "@tanstack/react-router"; -import { render as rtlRender, RenderOptions } from "@testing-library/react"; -import type { FunctionComponent, ReactElement, ReactNode } from "react"; - -// a root route for the test router. -const rootRoute = new RootRoute({ - component: () => ( - <> - - - ), -}); - -interface CustomRenderOptions extends Omit { - initialEntries?: string[]; - ui?: ReactElement; -} - -let currentRouter: ReturnType | null = null; - -/** - * Custom render function for testing components that need TanStack Router context. - * @param ui The main ReactElement to render (your component under test). - * @param options Custom render options including initialEntries for the router. - * @returns An object containing the testing-library render result and the router instance. - */ -const customRender = ( - ui: ReactElement, - options: CustomRenderOptions = {}, -) => { - const { initialEntries = ["/"], ...renderOptions } = options; - - // A specific route that renders the component under test (ui). - // It defaults to the first path in initialEntries or '/'. - const testComponentRoute = new Route({ - getParentRoute: () => rootRoute, - path: initialEntries[0] || "/", - component: () => ui, // The component passed to render will be the element for this route - }); - - const routeTree = rootRoute.addChildren([testComponentRoute]); - - const router = createRouter({ - history: createMemoryHistory({ initialEntries }), - routeTree, - // You can add default error components or other router options if needed for tests. - // defaultErrorComponent: ({ error }) =>
Test Error: {error.message}
, - }); - - currentRouter = router; // Store the router instance for access in tests - - const Wrapper: FunctionComponent<{ children?: ReactNode }> = ( - { children }, - ) => { - return ( - <> - - {children} - - ); - }; - - const renderResult = rtlRender(ui, { wrapper: Wrapper, ...renderOptions }); - - return { - ...renderResult, - router, - }; -}; - -export * from "@testing-library/react"; -export { customRender as render }; -export const getTestRouter = () => currentRouter; diff --git a/src/i18n/config.ts b/src/i18n/config.ts index 4a43392f..f203dab3 100644 --- a/src/i18n/config.ts +++ b/src/i18n/config.ts @@ -35,7 +35,7 @@ i18next "default": ["en"], }, fallbackNS: ["common", "ui", "dialog"], - debug: import.meta.env.DEV, + debug: import.meta.env.MODE === "development", supportedLngs: supportedLanguages?.map((lang) => lang.code), ns: [ "channels", diff --git a/src/i18n/locales/en/commandPalette.json b/src/i18n/locales/en/commandPalette.json index 7b82e97b..1eed8987 100644 --- a/src/i18n/locales/en/commandPalette.json +++ b/src/i18n/locales/en/commandPalette.json @@ -1,7 +1,7 @@ { "emptyState": "No results found.", "page": { - "title": "Command Palette" + "title": "Command Menu" }, "pinGroup": { "label": "Pin command group" diff --git a/src/i18n/locales/en/moduleConfig.json b/src/i18n/locales/en/moduleConfig.json index bca3468e..caacbe17 100644 --- a/src/i18n/locales/en/moduleConfig.json +++ b/src/i18n/locales/en/moduleConfig.json @@ -253,12 +253,12 @@ "description": "MQTT root topic to use for default/custom servers" }, "proxyToClientEnabled": { - "label": "Proxy to Client Enabled", - "description": "Use the client's internet connection for MQTT (feature only active in mobile apps)" + "label": "MQTT Client Proxy Enabled", + "description": "Utilizes the network connection to proxy MQTT messages to the client." }, "mapReportingEnabled": { "label": "Map Reporting Enabled", - "description": "Enable or disable map reporting" + "description": "Your node will periodically send an unencrypted map report packet to the configured MQTT server, this includes id, short and long name, approximate location, hardware model, role, firmware version, LoRa region, modem preset and primary channel name." }, "mapReportSettings": { "publishIntervalSecs": { diff --git a/src/i18n/locales/en/ui.json b/src/i18n/locales/en/ui.json index 6f0b6aa3..7e2e4708 100644 --- a/src/i18n/locales/en/ui.json +++ b/src/i18n/locales/en/ui.json @@ -80,6 +80,12 @@ }, "showPassword": { "label": "Show password" + }, + "deliveryStatus": { + "delivered": "Delivered", + "failed": "Delivery Failed", + "waiting": "Waiting", + "unknown": "Unknown" } }, "general": { diff --git a/src/index.css b/src/index.css index 1ae593b4..56103edb 100644 --- a/src/index.css +++ b/src/index.css @@ -3,6 +3,10 @@ @custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *)); +@view-transition { + navigation: auto; +} + @theme { --font-mono: Cascadia Code, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, diff --git a/src/pages/Dashboard/index.tsx b/src/pages/Dashboard/index.tsx index 69cfa2e2..6afc4546 100644 --- a/src/pages/Dashboard/index.tsx +++ b/src/pages/Dashboard/index.tsx @@ -4,14 +4,7 @@ import { useDeviceStore } from "@core/stores/deviceStore.ts"; import { Button } from "@components/UI/Button.tsx"; import { Separator } from "@components/UI/Seperator.tsx"; import { Subtle } from "@components/UI/Typography/Subtle.tsx"; -import { - BluetoothIcon, - ListPlusIcon, - NetworkIcon, - PlusIcon, - UsbIcon, - UsersIcon, -} from "lucide-react"; +import { ListPlusIcon, PlusIcon, UsersIcon } from "lucide-react"; import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import LanguageSwitcher from "@components/LanguageSwitcher.tsx"; @@ -60,32 +53,6 @@ export const Dashboard = () => { ?.longName ?? t("unknown.shortName")}

-
- {device.connection?.connType === "ble" && ( - <> - - {t( - "dashboard.connectionType_ble", - )} - - )} - {device.connection?.connType === "serial" && ( - <> - - {t( - "dashboard.connectionType_serial", - )} - - )} - {device.connection?.connType === "http" && ( - <> - - {t( - "dashboard.connectionType_network", - )} - - )} -
{ {t("dashboard.noDevicesTitle")} - {/* */} {t("dashboard.noDevicesDescription")} diff --git a/src/pages/Messages.test.tsx b/src/pages/Messages.test.tsx deleted file mode 100644 index 46fc0115..00000000 --- a/src/pages/Messages.test.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { fireEvent, render, screen } from "@testing-library/react"; -import { MessagesPage } from "./Messages.tsx"; -import { useDevice } from "../core/stores/deviceStore.ts"; -import { Protobuf } from "@meshtastic/core"; - -vi.mock("../core/stores/deviceStore", () => ({ - useDevice: vi.fn(), -})); - -const mockUseDevice = { - channels: new Map([ - [0, { - index: 0, - settings: { name: "Primary" }, - role: Protobuf.Channel.Channel_Role.PRIMARY, - }], - ]), - nodes: new Map([ - [0, { - num: 0, - user: { longName: "Test Node 0", shortName: "TN0", publicKey: "0000" }, - }], - [1111, { - num: 1111, - user: { longName: "Test Node 1", shortName: "TN1", publicKey: "12345" }, - }], - [2222, { - num: 2222, - user: { longName: "Test Node 2", shortName: "TN2", publicKey: "67890" }, - }], - [3333, { - num: 3333, - user: { longName: "Test Node 3", shortName: "TN3", publicKey: "11111" }, - }], - ]), - hardware: { myNodeNum: 1 }, - messages: { broadcast: new Map(), direct: new Map() }, - metadata: new Map(), - unreadCounts: new Map([[1111, 3], [2222, 10]]), - resetUnread: vi.fn(), - hasNodeError: vi.fn(), -}; - -describe.skip("Messages Page", () => { - beforeEach(() => { - vi.mocked(useDevice).mockReturnValue(mockUseDevice); - }); - - it("sorts unreads to the top", () => { - render(); - const buttonOrder = screen.getAllByRole("button").filter((b) => - b.textContent.includes("Test Node") - ); - expect(buttonOrder[0].textContent).toContain("TN2Test Node 210"); - expect(buttonOrder[1].textContent).toContain("TN1Test Node 13"); - expect(buttonOrder[2].textContent).toContain("TN0Test Node 0"); - expect(buttonOrder[3].textContent).toContain("TN3Test Node 3"); - }); - - it("updates unread when active chat changes", () => { - render(); - const nodeButton = - screen.getAllByRole("button").filter((b) => - b.textContent.includes("TN1Test Node 13") - )[0]; - fireEvent.click(nodeButton); - expect(mockUseDevice.resetUnread).toHaveBeenCalledWith(1111, 0); - }); - - it("does not update the incorrect node", () => { - render(); - const nodeButton = - screen.getAllByRole("button").filter((b) => - b.textContent.includes("TN1Test Node 1") - )[0]; - fireEvent.click(nodeButton); - expect(mockUseDevice.resetUnread).toHaveBeenCalledWith(1111, 0); - expect(mockUseDevice.unreadCounts.get(2222)).toBe(10); - }); -}); diff --git a/src/pages/Messages.tsx b/src/pages/Messages.tsx index 7e58d9ab..6a13fb35 100644 --- a/src/pages/Messages.tsx +++ b/src/pages/Messages.tsx @@ -28,9 +28,19 @@ import { Input } from "@components/UI/Input.tsx"; import { randId } from "@core/utils/randId.ts"; import { useTranslation } from "react-i18next"; import { useNavigate, useParams } from "@tanstack/react-router"; +import { messagesWithParamsRoute } from "@app/routes.tsx"; type NodeInfoWithUnread = Protobuf.Mesh.NodeInfo & { unreadCount: number }; +function SelectMessageChat() { + const { t } = useTranslation("messages"); + return ( +
+ {t("selectChatPrompt.text", { ns: "messages" })} +
+ ); +} + export const MessagesPage = () => { const { channels, @@ -46,7 +56,9 @@ export const MessagesPage = () => { getMessages, setMessageState, } = useMessageStore(); - const params = useParams({ from: "", shouldThrow: false }); + + const { type, chatId } = useParams({ from: messagesWithParamsRoute.id }); + const navigate = useNavigate(); const { toast } = useToast(); const { isCollapsed } = useSidebar(); @@ -54,34 +66,33 @@ export const MessagesPage = () => { const { t } = useTranslation(["messages", "channels", "ui"]); const deferredSearch = useDeferredValue(searchTerm); - const chatType = params.type === "direct" + const navigateToChat = useCallback((type: MessageType, id: string) => { + const typeParam = type === MessageType.Direct ? "direct" : "broadcast"; + navigate({ to: `/messages/${typeParam}/${id}` }); + }, [navigate]); + + const chatType = type === "direct" ? MessageType.Direct - : params.type === "broadcast" - ? MessageType.Broadcast - : undefined; - const activeChat = params.chatId ? Number(params.chatId) : undefined; + : MessageType.Broadcast; + const numericChatId = Number(chatId); const allChannels = Array.from(channels.values()); const filteredChannels = allChannels.filter( (ch) => ch.role !== Protobuf.Channel.Channel_Role.DISABLED, ); - const currentChannel = channels.get(activeChat); - const otherNode = getNode(activeChat); - - const isDirect = chatType === MessageType.Direct; - const isBroadcast = chatType === MessageType.Broadcast; - - const navigateToChat = useCallback((type: MessageType, chatId: number) => { - const typeParam = type === MessageType.Direct ? "direct" : "broadcast"; - navigate({ to: `/messages/${typeParam}/${chatId}` }); - }, [navigate]); useEffect(() => { - if (!params.type && !params.chatId && filteredChannels.length > 0) { + if (!type && !chatId && filteredChannels.length > 0) { const defaultChannel = filteredChannels[0]; - navigateToChat(MessageType.Broadcast, defaultChannel.index); + navigateToChat(MessageType.Broadcast, defaultChannel.index.toString()); } - }, [params.type, params.chatId, filteredChannels, navigateToChat]); + }, [type, chatId, filteredChannels, navigateToChat]); + + const currentChannel = channels.get(numericChatId); + const otherNode = getNode(numericChatId); + + const isDirect = chatType === MessageType.Direct; + const isBroadcast = chatType === MessageType.Broadcast; const filteredNodes = (): NodeInfoWithUnread[] => { const lowerCaseSearchTerm = deferredSearch.toLowerCase(); @@ -104,12 +115,8 @@ export const MessagesPage = () => { }; const sendText = useCallback(async (message: string) => { - const isDirect = chatType === MessageType.Direct; - const toValue = isDirect ? activeChat : MessageType.Broadcast; - - const channelValue = isDirect - ? Types.ChannelNumber.Primary - : activeChat ?? 0; + const toValue = isDirect ? numericChatId : MessageType.Broadcast; + const channelValue = isDirect ? Types.ChannelNumber.Primary : numericChatId; let messageId: number | undefined; @@ -123,16 +130,16 @@ export const MessagesPage = () => { if (messageId !== undefined) { if (chatType === MessageType.Broadcast) { setMessageState({ - type: chatType, + type: MessageType.Broadcast, channelId: channelValue, messageId, newState: MessageState.Ack, }); } else { setMessageState({ - type: chatType, + type: MessageType.Direct, nodeA: getMyNodeNum(), - nodeB: activeChat, + nodeB: numericChatId, messageId, newState: MessageState.Ack, }); @@ -145,23 +152,29 @@ export const MessagesPage = () => { const failedId = messageId ?? randId(); if (chatType === MessageType.Broadcast) { setMessageState({ - type: chatType, + type: MessageType.Broadcast, channelId: channelValue, messageId: failedId, newState: MessageState.Failed, }); - } else { // MessageType.Direct - const failedId = messageId ?? randId(); + } else { setMessageState({ - type: chatType, + type: MessageType.Direct, nodeA: getMyNodeNum(), - nodeB: activeChat, + nodeB: numericChatId, messageId: failedId, newState: MessageState.Failed, }); } } - }, [activeChat, chatType, connection, getMyNodeNum, setMessageState]); + }, [ + numericChatId, + chatId, + chatType, + connection, + getMyNodeNum, + setMessageState, + ]); const renderChatContent = () => { switch (chatType) { @@ -170,7 +183,7 @@ export const MessagesPage = () => { ); @@ -180,16 +193,12 @@ export const MessagesPage = () => { messages={getMessages({ type: MessageType.Direct, nodeA: getMyNodeNum(), - nodeB: activeChat, + nodeB: numericChatId, }).reverse()} /> ); default: - return ( -
- {t("selectChatPrompt.text", { ns: "messages" })} -
- ); + return ; } }; @@ -210,10 +219,10 @@ export const MessagesPage = () => { index: channel.index, ns: "channels", }))} - active={activeChat === channel.index && + active={numericChatId === channel.index && chatType === MessageType.Broadcast} onClick={() => { - navigateToChat(MessageType.Broadcast, channel.index); + navigateToChat(MessageType.Broadcast, channel.index.toString()); resetUnread(channel.index); }} > @@ -228,11 +237,12 @@ export const MessagesPage = () => { ), [ filteredChannels, unreadCounts, - activeChat, + numericChatId, chatType, isCollapsed, navigateToChat, resetUnread, + t, ]); const rightSidebar = useMemo( @@ -262,10 +272,10 @@ export const MessagesPage = () => { label={node.user?.longName ?? t("unknown.shortName")} count={node.unreadCount > 0 ? node.unreadCount : undefined} - active={activeChat === node.num && + active={numericChatId === node.num && chatType === MessageType.Direct} onClick={() => { - navigateToChat(MessageType.Direct, node.num); + navigateToChat(MessageType.Direct, node.num.toString()); resetUnread(node.num); }} > @@ -285,11 +295,12 @@ export const MessagesPage = () => { [ filteredNodes, searchTerm, - activeChat, + numericChatId, chatType, navigateToChat, resetUnread, hasNodeError, + t, ], ); @@ -330,7 +341,7 @@ export const MessagesPage = () => { {(isBroadcast || isDirect) ? ( diff --git a/src/pages/Nodes.tsx b/src/pages/Nodes/index.tsx similarity index 54% rename from src/pages/Nodes.tsx rename to src/pages/Nodes/index.tsx index c349cde3..8400ca2a 100644 --- a/src/pages/Nodes.tsx +++ b/src/pages/Nodes/index.tsx @@ -3,7 +3,11 @@ import { TracerouteResponseDialog } from "@app/components/Dialog/TracerouteRespo import { Sidebar } from "@components/Sidebar.tsx"; import { Avatar } from "@components/UI/Avatar.tsx"; import { Mono } from "@components/generic/Mono.tsx"; -import { Table } from "@components/generic/Table/index.tsx"; +import { + type DataRow, + type Heading, + Table, +} from "@components/generic/Table/index.tsx"; import { TimeAgo } from "@components/generic/TimeAgo.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; import { useAppStore } from "@core/stores/appStore.ts"; @@ -93,6 +97,123 @@ const NodesPage = (): JSX.Element => { setDialogOpen("nodeDetails", true); } + const tableHeadings: Heading[] = [ + { title: "", sortable: false }, + { title: t("nodesTable.headings.longName"), sortable: true }, + { title: t("nodesTable.headings.connection"), sortable: true }, + { title: t("nodesTable.headings.lastHeard"), sortable: true }, + { title: t("nodesTable.headings.encryption"), sortable: false }, + { title: t("unit.snr"), sortable: true }, + { title: t("nodesTable.headings.model"), sortable: true }, + { title: t("nodesTable.headings.macAddress"), sortable: true }, + ]; + + const tableRows: DataRow[] = filteredNodes.map((node) => { + const macAddress = base16 + .stringify(node.user?.macaddr ?? []) + .match(/.{1,2}/g) + ?.join(":") ?? t("unknown.shortName"); + + return { + id: node.num, + isFavorite: node.isFavorite, + cells: [ + { + content: ( + + ), + sortValue: node.user?.shortName ?? "", // Non-sortable column + }, + { + content: ( +

handleNodeInfoDialog(node.num)} + onKeyUp={(evt) => { + evt.key === "Enter" && handleNodeInfoDialog(node.num); + }} + className="cursor-pointer underline ml-2 whitespace-break-spaces" + tabIndex={0} + role="button" + > + {node.user?.longName ?? numberToHexUnpadded(node.num)} +

+ ), + sortValue: node.user?.longName ?? numberToHexUnpadded(node.num), + }, + { + content: ( + + {node.hopsAway !== undefined + ? node?.viaMqtt === false && node.hopsAway === 0 + ? t("nodesTable.connectionStatus.direct") + : `${node.hopsAway?.toString()} ${ + node.hopsAway ?? 0 > 1 + ? t("unit.hop.plural") + : t("unit.hops_one") + } ${t("nodesTable.connectionStatus.away")}` + : t("nodesTable.connectionStatus.unknown")} + {node?.viaMqtt === true + ? t("nodesTable.connectionStatus.viaMqtt") + : ""} + + ), + sortValue: node.hopsAway ?? Number.MAX_SAFE_INTEGER, + }, + { + content: ( + + {node.lastHeard === 0 + ?

{t("nodesTable.lastHeardStatus.never")}

+ : } +
+ ), + sortValue: node.lastHeard, + }, + { + content: ( + + {node.user?.publicKey && node.user?.publicKey.length > 0 + ? + : } + + ), + sortValue: "", // Non-sortable column + }, + { + content: ( + + {node.snr} + {t("unit.dbm")}/ + {Math.min( + Math.max((node.snr + 10) * 5, 0), + 100, + )}%/{/* Percentage */} + {(node.snr + 10) * 5} + {t("unit.raw")} + + ), + sortValue: node.snr, + }, + { + content: ( + + {Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]} + + ), + sortValue: Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0], + }, + { + content: {macAddress}, + sortValue: macAddress, + }, + ], + }; + }); + return ( <> {
[ -
- -
, -

handleNodeInfoDialog(node.num)} - onKeyUp={(evt) => { - evt.key === "Enter" && handleNodeInfoDialog(node.num); - }} - className="cursor-pointer underline ml-2 whitespace-break-spaces" - tabIndex={0} - role="button" - > - {node.user?.longName ?? numberToHexUnpadded(node.num)} -

, - - {node.hopsAway !== undefined - ? node?.viaMqtt === false && node.hopsAway === 0 - ? t("nodesTable.connectionStatus.direct") - : `${node.hopsAway?.toString()} ${ - node.hopsAway ?? 0 > 1 - ? t("unit.hop.plural") - : t("unit.hops_one") - } ${t("nodesTable.connectionStatus.away")}` - : t("nodesTable.connectionStatus.unknown")} - {node?.viaMqtt === true - ? t("nodesTable.connectionStatus.viaMqtt") - : ""} - , - - {node.lastHeard === 0 - ?

{t("nodesTable.lastHeardStatus.never")}

- : } -
, - - {node.user?.publicKey && node.user?.publicKey.length > 0 - ? - : } - , - - {node.snr} - {t("unit.dbm")}/ - {Math.min( - Math.max((node.snr + 10) * 5, 0), - 100, - )}%/{/* Percentage */} - {(node.snr + 10) * 5} - {t("unit.raw")} - , - - {Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]} - , - - {base16 - .stringify(node.user?.macaddr ?? []) - .match(/.{1,2}/g) - ?.join(":") ?? t("unknown.shortName")} - , - ])} + headings={tableHeadings} + rows={tableRows} /> () - -/* ROUTE_MANIFEST_START -{ - "routes": { - "__root__": { - "filePath": "__root.tsx", - "children": [] - } - } -} -ROUTE_MANIFEST_END */ diff --git a/src/routes.tsx b/src/routes.tsx index f936fd12..6cdbb7da 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -4,10 +4,11 @@ import MessagesPage from "@pages/Messages.tsx"; import MapPage from "@pages/Map/index.tsx"; import ConfigPage from "@pages/Config/index.tsx"; import ChannelsPage from "@pages/Channels.tsx"; -import NodesPage from "@pages/Nodes.tsx"; +import NodesPage from "@pages/Nodes/index.tsx"; import { createRootRoute } from "@tanstack/react-router"; import { App } from "./App.tsx"; import { DialogManager } from "@components/Dialog/DialogManager.tsx"; +import { z } from "zod"; const rootRoute = createRootRoute({ component: App, @@ -27,12 +28,42 @@ const messagesRoute = createRoute({ getParentRoute: () => rootRoute, path: "/messages", component: MessagesPage, + beforeLoad: ({ params }) => { + const DEFAULT_CHANNEL = 0; + + if (Object.values(params).length === 0) { + throw redirect({ + to: `/messages/broadcast/${DEFAULT_CHANNEL}`, + replace: true, + }); + } + }, +}); + +const chatIdSchema = z.string().refine((val) => { + const num = Number(val); + if (isNaN(num) || !Number.isInteger(num)) { + return false; + } + + const isChannelId = num >= 0 && num <= 10; + const isNodeId = num >= 1000000000 && num <= 9999999999; + + return isChannelId || isNodeId; +}, { + message: "Chat ID must be a channel (0-10) or a valid node ID.", }); -const messagesWithParamsRoute = createRoute({ +export const messagesWithParamsRoute = createRoute({ getParentRoute: () => rootRoute, path: "/messages/$type/$chatId", component: MessagesPage, + parseParams: (params) => ({ + type: z.enum(["direct", "broadcast"], { + errorMap: () => ({ message: 'Type must be "direct" or "broadcast".' }), + }).parse(params.type), + chatId: chatIdSchema.parse(params.chatId), + }), }); const mapRoute = createRoute({ diff --git a/src/tests/setupTests.ts b/src/tests/setup.ts similarity index 100% rename from src/tests/setupTests.ts rename to src/tests/setup.ts diff --git a/src/tests/test-utils.tsx b/src/tests/test-utils.tsx new file mode 100644 index 00000000..e28d5a11 --- /dev/null +++ b/src/tests/test-utils.tsx @@ -0,0 +1,37 @@ +import { ReactElement } from "react"; +import { render, RenderOptions } from "@testing-library/react"; +import { + createMemoryHistory, + createRouter, + RouterProvider, +} from "@tanstack/react-router"; +import "../i18n/config.ts"; +import { routeTree } from "../routeTree.gen.ts"; + +import { DeviceWrapper } from "@app/DeviceWrapper.tsx"; + +const Providers = () => { + const memoryHistory = createMemoryHistory({ + initialEntries: ["/"], + }); + + const router = createRouter({ + routeTree, + history: memoryHistory, + }); + + return ( + + + + ); +}; + +const renderWithProviders = ( + ui: ReactElement, + options?: Omit, +) => render(ui, { wrapper: Providers, ...options }); + +export * from "@testing-library/react"; + +export { renderWithProviders as render }; diff --git a/vite-env.d.ts b/vite-env.d.ts new file mode 100644 index 00000000..484e03a4 --- /dev/null +++ b/vite-env.d.ts @@ -0,0 +1,11 @@ +/// + +interface ImportMetaEnv { + readonly env: { + readonly VITE_COMMIT_HASH: string; + }; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/vite.config.ts b/vite.config.ts index 05daf0e3..83bba01f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -32,9 +32,9 @@ export default defineConfig({ targets: [ { src: "src/i18n/locales/**/*", - dest: "src/i18n/locales" - } - ] + dest: "src/i18n/locales", + }, + ], }), ], define: { diff --git a/vitest.config.ts b/vitest.config.ts index 537096e0..ec6d3d71 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,6 +3,8 @@ import react from "@vitejs/plugin-react"; import { defineConfig } from "vitest/config"; import { enableMapSet } from "immer"; +import process from "node:process"; + enableMapSet(); export default defineConfig({ plugins: [ @@ -25,6 +27,6 @@ export default defineConfig({ restoreMocks: true, root: path.resolve(process.cwd(), "./src"), include: ["**/*.{test,spec}.{ts,tsx}"], - setupFiles: ["./src/tests/setupTests.ts", "./src/core/utils/test.tsx"], + setupFiles: ["./src/tests/setup.ts"], }, });