diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index e0d41bd6..d3fe3f05 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -46,4 +46,5 @@ Check all that apply. If an item doesn't apply to your PR, you can leave it unch
- [ ] Code follows project style guidelines
- [ ] Documentation has been updated or added
- [ ] Tests have been added or updated
-- [ ] All i18n translation labels have bee added
+- [ ] All i18n translation labels have been added (read
+ CONTRIBUTING_I18N_DEVELOPER_GUIDE.md for more details)
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/CONTRIBUTING_I18N_DEVELOPER_GUIDE.md b/CONTRIBUTING_I18N_DEVELOPER_GUIDE.md
new file mode 100644
index 00000000..0b900de6
--- /dev/null
+++ b/CONTRIBUTING_I18N_DEVELOPER_GUIDE.md
@@ -0,0 +1,112 @@
+# i18n Developer Guide
+
+When developing new components, all user-facing text must be added as an i18n
+key and rendered using our translation functions. This ensures your UI can be
+translated into multiple languages.
+
+## Adding New i18n Keys
+
+### Search Before Creating
+
+Before adding a new key, please perform a quick search to see if one that fits
+your needs already exists. Many common labels like "Save," "Cancel," "Name,"
+"Description," "Loading...," or "Error" are likely already present, especially
+in the common.json namespace. Reusing existing keys prevents duplication and
+ensures consistency across the application. Using your code editor's search
+function across the /src/i18n/locales/en/ directory is an effective way to do
+this.
+
+### Key Naming and Structure Rules
+
+To maintain consistency and ease of use, please adhere to the following rules
+when creating new keys in the JSON files.
+
+- **Keys are camelCase:** `exampleKey`, `anotherExampleKey`.
+- **Avoid Deep Nesting:** One or two levels of nesting are acceptable for
+ grouping related keys (e.g., all labels for a specific menu). However, nesting
+ deeper than two levels should be avoided to maintain readability and ease of
+ use.
+ - **Good (1 level):**
+ ```json
+ "buttons": {
+ "save": "Save",
+ "cancel": "Cancel"
+ }
+ ```
+ - **Acceptable (2 levels):**
+ ```json
+ "userMenu": {
+ "items": {
+ "profile": "Profile",
+ "settings": "Settings"
+ }
+ }
+ ```
+ - **Avoid (3+ levels):**
+ ```json
+ "userMenu": {
+ "items": {
+ "actions": {
+ "viewProfile": "View Profile"
+ }
+ }
+ }
+ ```
+- **Organize for Retrieval, Not UI Layout:** Keys should be named logically for
+ easy retrieval, not to mirror the layout of your component.
+
+### Namespace Rules
+
+We use namespaces to organize keys. All source keys are added to the English
+(`en`) files located at `/src/i18n/locales/en/`. Place your new keys in the
+appropriate file based on these rules:
+
+- `common.json`:
+ - All button labels (`save`, `cancel`, `submit`, etc.).
+ - Any text that is repeated and used throughout the application (e.g.,
+ "Loading...", "Error").
+- `ui.json`:
+ - Labels and text specific to a distinct UI element or view that isn't a
+ dialog or a config page.
+- `dialog.json`:
+ - All text specific to modal dialogs (titles, body text, prompts).
+- `messages.json`:
+ - Text specifically related to the messaging interface.
+- `deviceConfig.json` & `moduleConfig.json`:
+ - Labels and descriptions for the settings on the Device and Module
+ configuration pages.
+
+## Using i18n Keys in Components
+
+We use the `useTranslation` hook from `react-i18next` to access the translation
+function, `t`.
+
+### Default Namespaces
+
+Our i18next configuration has fallback namespaces configured which includes
+`common`, `ui`, and `dialog`. This means you **do not** need to explicitly
+specify these namespaces when calling the hook. The system will automatically
+check these files for your key.
+
+For any keys in `common.json`, `ui.json`, or `dialog.json`, you can instantiate
+the hook simply:
+
+```typescript
+import { useTranslation } from "react-i18next";
+
+// In your component
+const { t } = useTranslation(["messages"]);
+
+// Usage
+return
{t("someMessageLabel")}
;
+```
+
+You can also specify the namespace on a per-call basis using the options object.
+This is useful if a component primarily uses a default namespace but needs a
+single key from another.
+
+```typescript
+const { t } = useTranslation();
+
+return {t("someMessageLabel", { ns: "messages" })}
;
+```
diff --git a/CONTRIBUTING_TRANSLATIONS.md b/CONTRIBUTING_TRANSLATIONS.md
new file mode 100644
index 00000000..66a7d646
--- /dev/null
+++ b/CONTRIBUTING_TRANSLATIONS.md
@@ -0,0 +1,31 @@
+# Contributing Translations
+
+Thank you for your interest in making the Meshtastic Web Client accessible to a
+global audience! Your translation efforts are greatly appreciated.
+
+## Our Translation Platform: Crowdin
+
+We manage all our translations through a platform called
+[Crowdin](https://crowdin.com/). This allows for a collaborative and streamlined
+translation process. All translation work should be done on our Crowdin project,
+not directly in the code repository via Pull Requests.
+
+### How to Get Started
+
+1. **Create a Crowdin Account:** If you don't already have one, sign up for a
+ free account on Crowdin.
+2. **Join Our Project:** Please ask for a link to our specific Crowdin project
+ on the Meshtastic Discord.
+3. **Request Translator Role:** Once you have an account, join the Meshtastic
+ Discord and notify an admin in the `#web` channel. They will grant you the
+ necessary permissions to start translating.
+4. **Start Translating:** Once you have your role, you can begin translating the
+ source labels into your native language directly on the Crowdin platform.
+
+### Language Activation
+
+A new language will only be added to the web client and appear in the language
+picker once its translation is 100% complete on Crowdin. The repository
+maintainers will handle this process once the milestone is reached.
+
+Thank you for helping us bring Meshtastic to more users around the world!
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
+ ? (
+
+ )
+ : (
+ // 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));
- },
- }}
/>
void } | undefined = {
@@ -18,7 +24,12 @@ vi.mock("@components/UI/Button.tsx", async () => {
const actual = await vi.importActual("@components/UI/Button.tsx");
return {
...actual,
- Button: (props) => ,
+ Button: (
+ props:
+ & JSX.IntrinsicAttributes
+ & ClassAttributes
+ & ButtonHTMLAttributes,
+ ) => ,
};
});
@@ -26,7 +37,12 @@ vi.mock("@components/UI/Input.tsx", async () => {
const actual = await vi.importActual("@components/UI/Input.tsx");
return {
...actual,
- Input: (props) => ,
+ Input: (
+ props:
+ & JSX.IntrinsicAttributes
+ & ClassAttributes
+ & InputHTMLAttributes,
+ ) => ,
};
});
diff --git a/src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.test.ts b/src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.test.ts
deleted file mode 100644
index 4e10a7b5..00000000
--- a/src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.test.ts
+++ /dev/null
@@ -1,86 +0,0 @@
-import { act, renderHook } from "@testing-library/react";
-import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
-import { beforeEach, describe, expect, it, Mock, vi } from "vitest";
-import { useDevice } from "@core/stores/deviceStore.ts";
-import { useMessageStore } from "@core/stores/messageStore/index.ts";
-
-vi.mock("@core/stores/messageStore", () => ({
- useMessageStore: vi.fn(() => ({ activeChat: "chat-123" })),
-}));
-vi.mock("@core/stores/deviceStore", () => ({
- useDevice: vi.fn(() => ({
- removeNode: vi.fn(),
- setDialogOpen: vi.fn(),
- getNodeError: vi.fn(),
- clearNodeError: vi.fn(),
- })),
-}));
-
-describe("useRefreshKeysDialog Hook", () => {
- let removeNodeMock: Mock;
- let setDialogOpenMock: Mock;
- let getNodeErrorMock: Mock;
- let clearNodeErrorMock: Mock;
-
- beforeEach(() => {
- vi.clearAllMocks();
-
- removeNodeMock = vi.fn();
- setDialogOpenMock = vi.fn();
- getNodeErrorMock = vi.fn().mockReturnValue(undefined);
- clearNodeErrorMock = vi.fn();
-
- vi.mocked(useDevice).mockReturnValue({
- removeNode: removeNodeMock,
- setDialogOpen: setDialogOpenMock,
- getNodeError: getNodeErrorMock,
- clearNodeError: clearNodeErrorMock,
- });
-
- vi.mocked(useMessageStore).mockReturnValue({
- activeChat: "chat-123",
- });
- });
-
- it("handleNodeRemove should remove the node and update dialog if there is an error", () => {
- getNodeErrorMock.mockReturnValue({ node: "node-abc" });
-
- const { result } = renderHook(() => useRefreshKeysDialog());
- act(() => {
- result.current.handleNodeRemove();
- });
-
- expect(getNodeErrorMock).toHaveBeenCalledTimes(1);
- expect(getNodeErrorMock).toHaveBeenCalledWith("chat-123");
- expect(clearNodeErrorMock).toHaveBeenCalledTimes(1);
- expect(clearNodeErrorMock).toHaveBeenCalledWith("chat-123");
- expect(removeNodeMock).toHaveBeenCalledTimes(1);
- expect(removeNodeMock).toHaveBeenCalledWith("node-abc");
- expect(setDialogOpenMock).toHaveBeenCalledTimes(1);
- expect(setDialogOpenMock).toHaveBeenCalledWith("refreshKeys", false);
- });
-
- it("handleNodeRemove should do nothing if there is no error", () => {
- const { result } = renderHook(() => useRefreshKeysDialog());
- act(() => {
- result.current.handleNodeRemove();
- });
-
- expect(getNodeErrorMock).toHaveBeenCalledTimes(1);
- expect(getNodeErrorMock).toHaveBeenCalledWith("chat-123");
- expect(clearNodeErrorMock).not.toHaveBeenCalled();
- expect(removeNodeMock).not.toHaveBeenCalled();
- expect(setDialogOpenMock).not.toHaveBeenCalled();
- });
-
- it("handleCloseDialog should close the dialog", () => {
- const { result } = renderHook(() => useRefreshKeysDialog());
-
- act(() => {
- result.current.handleCloseDialog();
- });
-
- expect(setDialogOpenMock).toHaveBeenCalledTimes(1);
- expect(setDialogOpenMock).toHaveBeenCalledWith("refreshKeys", false);
- });
-});
diff --git a/src/components/Dialog/TracerouteResponseDialog.tsx b/src/components/Dialog/TracerouteResponseDialog.tsx
index 650b9bfe..b21fd0f1 100644
--- a/src/components/Dialog/TracerouteResponseDialog.tsx
+++ b/src/components/Dialog/TracerouteResponseDialog.tsx
@@ -1,4 +1,4 @@
-import { useDevice } from "../../core/stores/deviceStore.ts";
+import { useDevice } from "@core/stores/deviceStore.ts";
import {
Dialog,
DialogClose,
@@ -31,13 +31,19 @@ export const TracerouteResponseDialog = ({
const snrTowards = (traceroute?.data.snrTowards ?? []).map((snr) => snr / 4);
const snrBack = (traceroute?.data.snrBack ?? []).map((snr) => snr / 4);
const from = getNode(traceroute?.from ?? 0);
- const longName = from?.user?.longName ??
+ const fromLongName = from?.user?.longName ??
(from ? `!${numberToHexUnpadded(from?.num)}` : t("unknown.shortName"));
- const shortName = from?.user?.shortName ??
+ const fromShortName = from?.user?.shortName ??
(from
? `${numberToHexUnpadded(from?.num).substring(0, 4)}`
: t("unknown.shortName"));
- const to = getNode(traceroute?.to ?? 0);
+
+ const toUser = getNode(traceroute?.to ?? 0);
+
+ if (!toUser || !from) {
+ return null;
+ }
+
return (
@@ -45,7 +51,7 @@ export const TracerouteResponseDialog = ({
{t("tracerouteResponse.title", {
- identifier: `${longName} (${shortName})`,
+ identifier: `${fromLongName} (${fromShortName})`,
})}
@@ -53,8 +59,8 @@ export const TracerouteResponseDialog = ({
diff --git a/src/components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.test.tsx b/src/components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.test.tsx
deleted file mode 100644
index 1584f7e8..00000000
--- a/src/components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.test.tsx
+++ /dev/null
@@ -1,134 +0,0 @@
-import { fireEvent, render, screen } from "@testing-library/react";
-import { describe, expect, it, vi } from "vitest";
-import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx";
-import {
- createMemoryHistory,
- createRootRoute,
- createRouter,
- RouterProvider,
-} from "@tanstack/react-router";
-import { eventBus } from "@core/utils/eventBus.ts";
-import { DeviceWrapper } from "@app/DeviceWrapper.tsx";
-
-const rootRoute = createRootRoute();
-
-describe.skip("UnsafeRolesDialog", () => {
- const mockDevice = {
- setDialogOpen: vi.fn(),
- };
-
- const renderWithProviders = (ui: React.ReactNode) => {
- const testRouter = createRouter({
- routeTree: rootRoute,
- history: createMemoryHistory(),
- });
-
- return render(
-
-
- {ui}
-
- ,
- );
- };
-
- it("renders the dialog when open is true", () => {
- renderWithProviders(
- ,
- );
-
- const dialog = screen.getByRole("dialog");
- expect(dialog).toBeInTheDocument();
-
- expect(screen.getByText(/I have read the/i)).toBeInTheDocument();
- expect(screen.getByText(/understand the implications/i))
- .toBeInTheDocument();
-
- const links = screen.getAllByRole("link");
- expect(links).toHaveLength(2);
- expect(links[0]).toHaveTextContent("Device Role Documentation");
- expect(links[1]).toHaveTextContent("Choosing The Right Device Role");
- });
-
- it("displays the correct links", () => {
- renderWithProviders(
- ,
- );
-
- const docLink = screen.getByRole("link", {
- name: /Device Role Documentation/i,
- });
- const blogLink = screen.getByRole("link", {
- name: /Choosing The Right Device Role/i,
- });
-
- expect(docLink).toHaveAttribute(
- "to",
- "https://meshtastic.org/docs/configuration/radio/device/",
- );
- expect(blogLink).toHaveAttribute(
- "to",
- "https://meshtastic.org/blog/choosing-the-right-device-role/",
- );
- });
-
- it("does not allow confirmation until checkbox is checked", () => {
- renderWithProviders(
- ,
- );
-
- const confirmButton = screen.getByRole("button", { name: /confirm/i });
-
- expect(confirmButton).toBeDisabled();
-
- const checkbox = screen.getByRole("checkbox");
- fireEvent.click(checkbox);
-
- expect(confirmButton).toBeEnabled();
- });
-
- it("emits the correct event when closing via close button", () => {
- const eventSpy = vi.spyOn(eventBus, "emit");
- renderWithProviders(
- ,
- );
-
- const dismissButton = screen.getByRole("button", { name: /close/i });
- fireEvent.click(dismissButton);
-
- expect(eventSpy).toHaveBeenCalledWith("dialog:unsafeRoles", {
- action: "dismiss",
- });
- });
-
- it("emits the correct event when dismissing", () => {
- const eventSpy = vi.spyOn(eventBus, "emit");
- renderWithProviders(
- ,
- );
-
- const dismissButton = screen.getByRole("button", { name: /dismiss/i });
- fireEvent.click(dismissButton);
-
- expect(eventSpy).toHaveBeenCalledWith("dialog:unsafeRoles", {
- action: "dismiss",
- });
- });
-
- it("emits the correct event when confirming", () => {
- const eventSpy = vi.spyOn(eventBus, "emit");
- renderWithProviders(
- ,
- );
-
- const checkbox = screen.getByRole("checkbox");
- const confirmButton = screen.getByRole("button", { name: /confirm/i });
-
- fireEvent.click(checkbox);
- fireEvent.click(confirmButton);
-
- expect(eventSpy).toHaveBeenCalledWith("dialog:unsafeRoles", {
- action: "confirm",
- });
- });
-});
diff --git a/src/components/Form/DynamicForm.test.tsx b/src/components/Form/DynamicForm.test.tsx
deleted file mode 100644
index 2e776c50..00000000
--- a/src/components/Form/DynamicForm.test.tsx
+++ /dev/null
@@ -1,314 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-import { fireEvent, render, screen, waitFor } from "@core/utils/test.tsx";
-import { DynamicForm } from "./DynamicForm.tsx";
-import { z } from "zod/v4";
-import { useAppStore } from "@core/stores/appStore.ts";
-import userEvent from "@testing-library/user-event";
-
-vi.mock("react-i18next", () => ({
- useTranslation: () => ({
- t: (key: string | string[]) => (Array.isArray(key) ? key[0] : key),
- }),
-}));
-
-const addErrorMock = vi.fn();
-const removeErrorMock = vi.fn();
-
-vi.mock("@core/stores/appStore.ts", () => ({
- useAppStore: () => ({
- addError: addErrorMock,
- removeError: removeErrorMock,
- }),
-}));
-
-describe.skip("DynamicForm", () => {
- const schema = z.object({
- name: z.string().min(3, { message: "Too short" }),
- });
-
- const fieldGroups = [
- {
- label: "Test Group",
- description: "Testing validation",
- fields: [
- {
- type: "text",
- id: "name",
- name: "name",
- label: "Name",
- description: "Enter your name",
- properties: {},
- },
- ],
- },
- ];
-
- it("shows validation error when input is too short", async () => {
- render(
- >
- onSubmit={vi.fn()}
- validationSchema={schema}
- defaultValues={{ name: "" }}
- fieldGroups={fieldGroups}
- />,
- );
- const input = screen.getByLabelText("Name") as HTMLInputElement;
-
- fireEvent.input(input, { target: { value: "ab" } });
-
- const error = await screen.findByText(
- "formValidation.tooSmall.string",
- );
- expect(error).toBeVisible();
- });
-
- it("clears validation error when input becomes valid", async () => {
- render(
- >
- onSubmit={vi.fn()}
- validationSchema={schema}
- defaultValues={{ name: "" }}
- fieldGroups={fieldGroups}
- />,
- );
- const input = screen.getByLabelText("Name") as HTMLInputElement;
-
- fireEvent.input(input, { target: { value: "ab" } });
- expect(
- await screen.findByText("formValidation.tooSmall.string"),
- ).toBeVisible();
-
- fireEvent.input(input, { target: { value: "abcd" } });
- await waitFor(() =>
- expect(
- screen.queryByText("formValidation.tooSmall.string"),
- ).toBeNull()
- );
- });
-
- it("calls onSubmit when form is valid onChange", async () => {
- const onSubmit = vi.fn();
- render(
- >
- onSubmit={onSubmit}
- validationSchema={schema}
- defaultValues={{ name: "" }}
- fieldGroups={fieldGroups}
- />,
- );
-
- const input = screen.getByLabelText("Name") as HTMLInputElement;
-
- fireEvent.input(input, { target: { value: "ab" } });
- expect(
- await screen.findByText("formValidation.tooSmall.string"),
- ).toBeVisible();
-
- fireEvent.input(input, { target: { value: "abcd" } });
-
- await waitFor(() => {
- expect(onSubmit).toHaveBeenCalledTimes(1);
- });
-
- expect(onSubmit).toHaveBeenCalledWith(
- { name: "abcd" },
- expect.any(Object),
- );
- });
-
- it("renders a button and only calls onSubmit on click with submitType='onSubmit'", async () => {
- // Use the userEvent setup
- const user = userEvent.setup();
- const onSubmit = vi.fn();
-
- render(
- >
- onSubmit={onSubmit}
- submitType="onSubmit"
- hasSubmitButton
- validationSchema={schema}
- defaultValues={{ name: "" }}
- fieldGroups={fieldGroups}
- />,
- );
-
- const nameInput = screen.getByLabelText("Name");
- const submitButton = screen.getByRole("button", { name: /submit/i });
-
- expect(submitButton).toBeInTheDocument();
- await user.type(nameInput, "ab");
-
- expect(await screen.findByText("formValidation.tooSmall.string"))
- .toBeInTheDocument();
- await user.click(submitButton);
- expect(onSubmit).not.toHaveBeenCalled();
-
- await user.clear(nameInput);
- await user.type(nameInput, "abcd");
-
- await waitFor(() => {
- expect(screen.queryByText("formValidation.tooSmall.string")).not
- .toBeInTheDocument();
- });
- await user.click(submitButton);
- await waitFor(() => {
- expect(onSubmit).toHaveBeenCalledTimes(1);
- });
-
- expect(onSubmit).toHaveBeenCalledWith({ name: "abcd" }, expect.any(Object));
- });
-
- it("renders defaultValues correctly", () => {
- render(
-
- onSubmit={vi.fn()}
- // no validationSchema
- defaultValues={{ name: "Alice" }}
- fieldGroups={[
- {
- label: "Group",
- description: "",
- fields: [
- {
- type: "text",
- name: "name",
- label: "Name",
- description: "",
- properties: {},
- },
- ],
- },
- ]}
- />,
- );
- const input = screen.getByLabelText("Name") as HTMLInputElement;
- expect(input.value).toBe("Alice");
- });
-
- it("toggles disabled state based on disabledBy rules", async () => {
- const schema = z.object({
- enable: z.boolean(),
- follow: z.string(),
- });
- render(
- >
- onSubmit={vi.fn()}
- validationSchema={schema}
- defaultValues={{ enable: false, follow: "" }}
- fieldGroups={[
- {
- label: "Group",
- description: "",
- fields: [
- {
- type: "toggle",
- name: "enable",
- label: "enable",
- description: "",
- },
- {
- type: "text",
- name: "follow",
- label: "follow",
- description: "",
- disabledBy: [{ fieldName: "enable" }],
- properties: {},
- },
- ],
- },
- ]}
- />,
- );
- const enable = screen.getByRole("switch", {
- name: "enable",
- }) as HTMLInputElement;
-
- const follow = screen.getByLabelText("follow") as HTMLInputElement;
- await waitFor(() => {
- expect(enable.getAttribute("aria-checked")).toBe("false");
- expect(follow).toBeDisabled();
- });
-
- fireEvent.click(enable);
- await waitFor(() => {
- expect(enable.getAttribute("aria-checked")).toBe("true");
- expect(follow).not.toBeDisabled();
- });
- });
-
- it("always calls onSubmit onChange when no validationSchema is provided", async () => {
- const onSubmit = vi.fn();
- render(
-
- onSubmit={onSubmit}
- // no validationSchema
- defaultValues={{ foo: "" }}
- fieldGroups={[
- {
- label: "G",
- description: "",
- fields: [
- {
- type: "text",
- name: "foo",
- label: "Foo",
- description: "",
- properties: {},
- },
- ],
- },
- ]}
- />,
- );
- const input = screen.getByLabelText("Foo") as HTMLInputElement;
- fireEvent.input(input, { target: { value: "bar" } });
-
- await waitFor(() => {
- expect(onSubmit).toHaveBeenCalledTimes(1);
- expect(onSubmit).toHaveBeenCalledWith({ foo: "bar" }, expect.any(Object));
- });
- });
-
- it("syncs errors to appStore when formId is set", async () => {
- const { addError, removeError } = useAppStore();
- const schema = z.object({ foo: z.string().min(2) });
- const groups = [
- {
- label: "G",
- description: "",
- fields: [
- {
- type: "text",
- name: "foo",
- label: "Foo",
- description: "",
- properties: {},
- },
- ],
- },
- ];
-
- render(
- >
- onSubmit={vi.fn()}
- formId="myForm"
- validationSchema={schema}
- defaultValues={{ foo: "" }}
- fieldGroups={groups}
- />,
- );
- const input = screen.getByLabelText("Foo") as HTMLInputElement;
-
- fireEvent.input(input, { target: { value: "a" } });
- await screen.findByText(/tooSmall/i);
-
- expect(addError).toHaveBeenCalledWith("foo", "");
- expect(addError).toHaveBeenCalledWith("myForm", "");
-
- fireEvent.input(input, { target: { value: "abc" } });
- await waitFor(() => {
- expect(removeError).toHaveBeenCalledWith("foo");
- expect(removeError).toHaveBeenCalledWith("myForm");
- });
- });
-});
diff --git a/src/components/Form/FormSelect.tsx b/src/components/Form/FormSelect.tsx
index 8bb7ce33..3b4cce04 100644
--- a/src/components/Form/FormSelect.tsx
+++ b/src/components/Form/FormSelect.tsx
@@ -43,14 +43,19 @@ export function SelectInput({
invalid,
}: GenericFormElementProps>) {
const {
- field: { value, onChange, ...rest },
+ field: { value, onChange, ref, onBlur, ...rest },
} = useController({
name: field.name,
control,
});
- const { enumValue, formatEnumName, ...remainingProperties } =
- field.properties;
+ const {
+ enumValue,
+ formatEnumName,
+ defaultValue,
+ className,
+ ...remainingProperties
+ } = field.properties;
const valueToKeyMap: Record = {};
const optionsEnumValues: [string, number][] = [];
@@ -80,18 +85,21 @@ export function SelectInput({
onValueChange={handleValueChange}
disabled={disabled}
value={value?.toString()}
- {...remainingProperties}
+ defaultValue={defaultValue?.toString()}
{...rest}
>
diff --git a/src/components/PageComponents/Config/Device/Device.test.tsx b/src/components/PageComponents/Config/Device/Device.test.tsx
deleted file mode 100644
index 538f6a5d..00000000
--- a/src/components/PageComponents/Config/Device/Device.test.tsx
+++ /dev/null
@@ -1,131 +0,0 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { fireEvent, render, screen, waitFor } from "@testing-library/react";
-import { Device } from "@components/PageComponents/Config/Device/index.tsx";
-import { useDevice } from "@core/stores/deviceStore.ts";
-import { useUnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
-import { Protobuf } from "@meshtastic/core";
-
-vi.mock("@core/stores/deviceStore.ts", () => ({
- useDevice: vi.fn(),
-}));
-
-vi.mock("@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts", () => ({
- useUnsafeRolesDialog: vi.fn(),
-}));
-
-// Mock the DynamicForm component since we're testing the Device component,
-// not the DynamicForm implementation
-vi.mock("@components/Form/DynamicForm", () => ({
- DynamicForm: vi.fn(({ onSubmit }) => {
- // Render a simplified version of the form for testing
- return (
-
- {
- // Simulate the validation and submission process
- const mockData = { role: e.target.value };
- onSubmit(mockData);
- }}
- >
- {Object.entries(Protobuf.Config.Config_DeviceConfig_Role).map((
- [key, value],
- ) => (
-
- {key}
-
- ))}
-
- onSubmit({ role: "CLIENT" })}
- >
- Submit
-
-
- );
- }),
-}));
-
-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 (
-
- );
- },
- };
-});
-
-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 (
-
- {icon && iconAlignment === "left" && (
- {icon}
- )}
- {children}
-
- {icon && iconAlignment === "right" && (
- {icon}
- )}
-
- );
- },
-);
-Button.displayName = "Button";
+const Button = ({
+ className,
+ variant,
+ size,
+ disabled,
+ icon,
+ iconAlignment = "left",
+ children,
+ ...props
+}: ButtonProps) => {
+ return (
+
+ {icon && iconAlignment === "left" && (
+ {icon}
+ )}
+ {children}
+ {icon && iconAlignment === "right" && (
+ {icon}
+ )}
+
+ );
+};
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) => {
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) => {
- {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
- ? (
-
- {item}
-
- )
- : (
-
- {item}
-
- );
- })}
-
- );
- })}
+ {sortedRows.map((row) => (
+
+ {row.cells.map((cell, cellIndex) =>
+ cellIndex === 0
+ ? (
+
+ {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 7735dec9..b163aa5c 100644
--- a/src/core/stores/deviceStore.ts
+++ b/src/core/stores/deviceStore.ts
@@ -144,7 +144,7 @@ export const useDeviceStore = createStore((set, get) => ({
addDevice: (id: number) => {
set(
- produce((draft) => {
+ produce((draft) => {
draft.devices.set(id, {
id,
status: Types.DeviceStatusEnum.DeviceDisconnected,
@@ -181,7 +181,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;
@@ -191,7 +191,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) {
@@ -233,7 +233,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) {
@@ -301,7 +301,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(
@@ -320,7 +320,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(
@@ -432,7 +432,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;
@@ -442,7 +442,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;
@@ -452,7 +452,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);
@@ -462,7 +462,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) =>
@@ -479,7 +479,7 @@ export const useDeviceStore = createStore((set, get) => ({
},
addNodeInfo: (nodeInfo) => {
set(
- produce((draft) => {
+ produce((draft) => {
const device = draft.devices.get(id);
if (!device) return;
@@ -489,7 +489,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;
@@ -499,7 +499,7 @@ export const useDeviceStore = createStore((set, get) => ({
},
addUser: (user) => {
set(
- produce((draft) => {
+ produce((draft) => {
const device = draft.devices.get(id);
if (!device) {
return;
@@ -514,7 +514,7 @@ export const useDeviceStore = createStore((set, get) => ({
},
addPosition: (position) => {
set(
- produce((draft) => {
+ produce((draft) => {
const device = draft.devices.get(id);
if (!device) {
return;
@@ -529,7 +529,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;
@@ -539,7 +539,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);
@@ -549,7 +549,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) ?? [];
@@ -558,9 +558,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;
@@ -571,7 +571,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;
@@ -586,7 +586,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);
@@ -609,7 +609,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;
@@ -617,9 +617,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 });
@@ -629,7 +629,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);
@@ -649,7 +649,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;
@@ -659,7 +659,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);
@@ -735,10 +735,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;
+ }
}),
);
},
@@ -756,10 +758,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;
+ }
}),
);
},
@@ -776,7 +780,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"],
},
});