Browse Source

Merge branch 'master' into unread-counts

pull/497/head
Hunter Thornsberry 1 year ago
committed by GitHub
parent
commit
11b052e5bb
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 2
      .gitignore
  2. 68
      README.md
  3. 279
      deno.lock
  4. 3
      package.json
  5. 9
      src/components/Dialog/DialogManager.tsx
  6. 12
      src/components/Dialog/NewDeviceDialog.tsx
  7. 190
      src/components/Dialog/NodeDetailsDialog.tsx
  8. 73
      src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.test.tsx
  9. 177
      src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx
  10. 55
      src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.test.tsx
  11. 61
      src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx
  12. 77
      src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.test.ts
  13. 28
      src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.ts
  14. 2
      src/components/Form/DynamicForm.tsx
  15. 64
      src/components/PageComponents/Channel.tsx
  16. 6
      src/components/PageComponents/Connect/Serial.tsx
  17. 5
      src/components/PageComponents/Messages/ChannelChat.tsx
  18. 122
      src/components/PageComponents/Messages/Message.tsx
  19. 126
      src/components/PageComponents/Messages/MessageItem.tsx
  20. 95
      src/components/PageComponents/Messages/TraceRoute.test.tsx
  21. 78
      src/components/PageComponents/Messages/TraceRoute.tsx
  22. 2
      src/components/Sidebar.tsx
  23. 15
      src/components/ThemeSwitcher.tsx
  24. 11
      src/components/UI/Avatar.tsx
  25. 2
      src/components/UI/Button.tsx
  26. 12
      src/components/generic/Table/index.tsx
  27. 57
      src/core/stores/deviceStore.ts
  28. 34
      src/core/subscriptions.ts
  29. 8
      src/index.tsx
  30. 6
      src/pages/Messages.tsx
  31. 109
      src/pages/Nodes.tsx
  32. 9
      vite.config.ts

2
.gitignore

@ -2,6 +2,6 @@ dist
node_modules node_modules
stats.html stats.html
.vercel .vercel
.vite/deps .vite
dev-dist dev-dist
__screenshots__* __screenshots__*

68
README.md

@ -139,46 +139,28 @@ reasons:
- **Web Standard APIs**: Uses browser-compatible APIs, making code more portable - **Web Standard APIs**: Uses browser-compatible APIs, making code more portable
between server and client environments. between server and client environments.
### Debugging ### Contributing
#### Debugging with React Scan We welcome contributions! Here’s how the deployment flow works for pull
requests:
Meshtastic Web Client has included the library
[React Scan](https://github.com/aidenybai/react-scan) to help you identify and - **Preview Deployments:**\
resolve render performance issues during development. Every pull request automatically generates a preview deployment on Vercel.
This allows you and reviewers to easily preview changes before merging.
React's comparison-by-reference approach to props makes it easy to inadvertently
cause unnecessary re-renders, especially with: - **Staging Environment (`client-test`):**\
Once your PR is merged, your changes will be available on our staging site:
- Inline function callbacks (`onClick={() => handleClick()}`) [client-test.meshtastic.org](https://client-test.meshtastic.org/).\
- Object literals (`style={{ color: "purple" }}`) This environment supports rapid feature iteration and testing without
- Array literals (`items={[1, 2, 3]}`) impacting the production site.
These are recreated on every render, causing child components to re-render even - **Production Releases:**\
when nothing has actually changed. At regular intervals, stable and fully tested releases are promoted to our
production site: [client.meshtastic.org](https://client.meshtastic.org/).\
Unlike React DevTools, React Scan specifically focuses on performance This is the primary interface used by the public to connect with their
optimization by: Meshtastic nodes.
- Clearly distinguishing between necessary and unnecessary renders Please review our
- Providing render counts for components [Contribution Guidelines](https://github.com/meshtastic/web/blob/master/CONTRIBUTING.md)
- Highlighting slow-rendering components before submitting a pull request. We appreciate your help in making the project
- Offering a dedicated performance debugging experience better!
#### Usage
When experiencing slow renders, run:
```bash
deno task dev:scan
```
This will allow you to discover the following about your components and pages:
- Components with excessive re-renders
- Performance bottlenecks in the render tree
- Expensive hook operations
- Props that change reference on every render
Use these insights to apply targeted optimizations like `React.memo()`,
`useCallback()`, or `useMemo()` where they'll have the most impact.

279
deno.lock

@ -56,7 +56,6 @@
"npm:react-hook-form@^7.54.2": "[email protected]", "npm:react-hook-form@^7.54.2": "[email protected]",
"npm:[email protected]": "[email protected][email protected][email protected][email protected]", "npm:[email protected]": "[email protected][email protected][email protected][email protected]",
"npm:react-qrcode-logo@3": "[email protected][email protected][email protected]", "npm:react-qrcode-logo@3": "[email protected][email protected][email protected]",
"npm:react-scan@~0.2.8": "[email protected][email protected][email protected][email protected]",
"npm:react@19": "19.0.0", "npm:react@19": "19.0.0",
"npm:rfc4648@^1.5.4": "1.5.4", "npm:rfc4648@^1.5.4": "1.5.4",
"npm:simple-git-hooks@^2.11.1": "2.11.1", "npm:simple-git-hooks@^2.11.1": "2.11.1",
@ -899,168 +898,78 @@
"tough-cookie" "tough-cookie"
] ]
}, },
"@clack/[email protected]": {
"integrity": "sha512-5cfhQNH+1VQ2xLQlmzXMqUoiaH0lRBq9/CLW9lTyMbuKLC3+xEK01tHVvyut++mLOn5urSHmkm6I0Lg9MaJSTQ==",
"dependencies": [
"picocolors",
"sisteransi"
]
},
"@clack/[email protected]": {
"integrity": "sha512-6b9Ab2UiZwJYA9iMyboYyW9yJvAO9V753ZhS+DHKEjZRKAxPPOb7MXXu84lsPFG+vZt6FRFniZ8rXi+zCIw4yQ==",
"dependencies": [
"@clack/core",
"picocolors",
"sisteransi"
]
},
"@esbuild/[email protected]": {
"integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA=="
},
"@esbuild/[email protected]": { "@esbuild/[email protected]": {
"integrity": "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==" "integrity": "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ=="
}, },
"@esbuild/[email protected]": {
"integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg=="
},
"@esbuild/[email protected]": { "@esbuild/[email protected]": {
"integrity": "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==" "integrity": "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g=="
}, },
"@esbuild/[email protected]": {
"integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q=="
},
"@esbuild/[email protected]": { "@esbuild/[email protected]": {
"integrity": "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==" "integrity": "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g=="
}, },
"@esbuild/[email protected]": {
"integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw=="
},
"@esbuild/[email protected]": { "@esbuild/[email protected]": {
"integrity": "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==" "integrity": "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg=="
}, },
"@esbuild/[email protected]": {
"integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA=="
},
"@esbuild/[email protected]": { "@esbuild/[email protected]": {
"integrity": "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==" "integrity": "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw=="
}, },
"@esbuild/[email protected]": {
"integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA=="
},
"@esbuild/[email protected]": { "@esbuild/[email protected]": {
"integrity": "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==" "integrity": "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg=="
}, },
"@esbuild/[email protected]": {
"integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg=="
},
"@esbuild/[email protected]": { "@esbuild/[email protected]": {
"integrity": "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==" "integrity": "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w=="
}, },
"@esbuild/[email protected]": {
"integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q=="
},
"@esbuild/[email protected]": { "@esbuild/[email protected]": {
"integrity": "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==" "integrity": "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A=="
}, },
"@esbuild/[email protected]": {
"integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg=="
},
"@esbuild/[email protected]": { "@esbuild/[email protected]": {
"integrity": "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==" "integrity": "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg=="
}, },
"@esbuild/[email protected]": {
"integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA=="
},
"@esbuild/[email protected]": { "@esbuild/[email protected]": {
"integrity": "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==" "integrity": "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg=="
}, },
"@esbuild/[email protected]": {
"integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw=="
},
"@esbuild/[email protected]": { "@esbuild/[email protected]": {
"integrity": "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==" "integrity": "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg=="
}, },
"@esbuild/[email protected]": {
"integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ=="
},
"@esbuild/[email protected]": { "@esbuild/[email protected]": {
"integrity": "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==" "integrity": "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw=="
}, },
"@esbuild/[email protected]": {
"integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw=="
},
"@esbuild/[email protected]": { "@esbuild/[email protected]": {
"integrity": "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==" "integrity": "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ=="
}, },
"@esbuild/[email protected]": {
"integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw=="
},
"@esbuild/[email protected]": { "@esbuild/[email protected]": {
"integrity": "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==" "integrity": "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw=="
}, },
"@esbuild/[email protected]": {
"integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q=="
},
"@esbuild/[email protected]": { "@esbuild/[email protected]": {
"integrity": "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==" "integrity": "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA=="
}, },
"@esbuild/[email protected]": {
"integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw=="
},
"@esbuild/[email protected]": { "@esbuild/[email protected]": {
"integrity": "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==" "integrity": "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA=="
}, },
"@esbuild/[email protected]": {
"integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q=="
},
"@esbuild/[email protected]": { "@esbuild/[email protected]": {
"integrity": "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==" "integrity": "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw=="
}, },
"@esbuild/[email protected]": {
"integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw=="
},
"@esbuild/[email protected]": { "@esbuild/[email protected]": {
"integrity": "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==" "integrity": "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw=="
}, },
"@esbuild/[email protected]": {
"integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw=="
},
"@esbuild/[email protected]": { "@esbuild/[email protected]": {
"integrity": "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==" "integrity": "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA=="
}, },
"@esbuild/[email protected]": {
"integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A=="
},
"@esbuild/[email protected]": { "@esbuild/[email protected]": {
"integrity": "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==" "integrity": "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw=="
}, },
"@esbuild/[email protected]": {
"integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA=="
},
"@esbuild/[email protected]": { "@esbuild/[email protected]": {
"integrity": "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==" "integrity": "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg=="
}, },
"@esbuild/[email protected]": {
"integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig=="
},
"@esbuild/[email protected]": { "@esbuild/[email protected]": {
"integrity": "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==" "integrity": "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg=="
}, },
"@esbuild/[email protected]": {
"integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ=="
},
"@esbuild/[email protected]": { "@esbuild/[email protected]": {
"integrity": "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==" "integrity": "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw=="
}, },
"@esbuild/[email protected]": {
"integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA=="
},
"@esbuild/[email protected]": { "@esbuild/[email protected]": {
"integrity": "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==" "integrity": "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA=="
}, },
"@esbuild/[email protected]": {
"integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg=="
},
"@esbuild/[email protected]": { "@esbuild/[email protected]": {
"integrity": "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==" "integrity": "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ=="
}, },
@ -1099,7 +1008,7 @@
"dependencies": [ "dependencies": [
"@inquirer/core", "@inquirer/core",
"@inquirer/type", "@inquirer/type",
"@types/node@22.13.8" "@types/node"
] ]
}, },
"@inquirer/[email protected]_@[email protected]": { "@inquirer/[email protected]_@[email protected]": {
@ -1107,7 +1016,7 @@
"dependencies": [ "dependencies": [
"@inquirer/figures", "@inquirer/figures",
"@inquirer/type", "@inquirer/type",
"@types/node@22.13.8", "@types/node",
"ansi-escapes", "ansi-escapes",
"cli-width", "cli-width",
"mute-stream", "mute-stream",
@ -1122,7 +1031,7 @@
"@inquirer/[email protected]_@[email protected]": { "@inquirer/[email protected]_@[email protected]": {
"integrity": "sha512-2MNFrDY8jkFYc9Il9DgLsHhMzuHnOYM1+CUYVWbzu9oT0hC7V7EcYvdCKeoll/Fcci04A+ERZ9wcc7cQ8lTkIA==", "integrity": "sha512-2MNFrDY8jkFYc9Il9DgLsHhMzuHnOYM1+CUYVWbzu9oT0hC7V7EcYvdCKeoll/Fcci04A+ERZ9wcc7cQ8lTkIA==",
"dependencies": [ "dependencies": [
"@types/node@22.13.8" "@types/node"
] ]
}, },
"@isaacs/[email protected]": { "@isaacs/[email protected]": {
@ -1305,29 +1214,12 @@
"@open-draft/[email protected]": { "@open-draft/[email protected]": {
"integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==" "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg=="
}, },
"@pivanov/[email protected][email protected][email protected][email protected]": {
"integrity": "sha512-JQ/pXeG9/Yq3UuwH2Xp4F6bSAIDGzbxT0Vrg/82tMi3Yp+Ps9AYzjSDE+zfvBRqc7J11V6MMonUrWj4+2dYgrg==",
"dependencies": [
"react",
"react-dom"
]
},
"@pkgjs/[email protected]": { "@pkgjs/[email protected]": {
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==" "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="
}, },
"@polka/[email protected]": { "@polka/[email protected]": {
"integrity": "sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==" "integrity": "sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw=="
}, },
"@preact/[email protected]": {
"integrity": "sha512-OBvUsRZqNmjzCZXWLxkZfhcgT+Fk8DDcT/8vD6a1xhDemodyy87UJRJfASMuSD8FaAIeGgGm85ydXhm7lr4fyA=="
},
"@preact/[email protected][email protected]": {
"integrity": "sha512-naxcJgUJ6BTOROJ7C3QML7KvwKwCXQJYTc5L/b0eEsdYgPB6SxwoQ1vDGcS0Q7GVjAenVq/tXrybVdFShHYZWg==",
"dependencies": [
"@preact/signals-core",
"preact"
]
},
"@radix-ui/[email protected]": { "@radix-ui/[email protected]": {
"integrity": "sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==" "integrity": "sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ=="
}, },
@ -3531,16 +3423,10 @@
"@types/pbf" "@types/pbf"
] ]
}, },
"@types/[email protected]": {
"integrity": "sha512-9RV2zST+0s3EhfrMZIhrz2bhuhBwxgkbHEwP2gtGWPjBzVQjifMzJ9exw7aDZhR1wbpj8zBrfp3bo8oJcGiUUw==",
"dependencies": [
"[email protected]"
]
},
"@types/[email protected]": { "@types/[email protected]": {
"integrity": "sha512-G3EfaZS+iOGYWLLRCEAXdWK9my08oHNZ+FHluRiggIYJPOXzhOiDgpVCUHaUvyIC5/fj7C/p637jdzC666AOKQ==", "integrity": "sha512-G3EfaZS+iOGYWLLRCEAXdWK9my08oHNZ+FHluRiggIYJPOXzhOiDgpVCUHaUvyIC5/fj7C/p637jdzC666AOKQ==",
"dependencies": [ "dependencies": [
"undici-types@6.20.0" "undici-types"
] ]
}, },
"@types/[email protected]": { "@types/[email protected]": {
@ -3848,9 +3734,6 @@
"[email protected]": { "[email protected]": {
"integrity": "sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug==" "integrity": "sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug=="
}, },
"[email protected]": {
"integrity": "sha512-LTCos3SmOJHrag0qF91tLUZMMw6wA+i15ESRBp71pvfNlTMYcxYoJHJ/pvFhd+29Wm5vfgVxBHV7kP5OKUUipg=="
},
"[email protected]": { "[email protected]": {
"integrity": "sha512-k8TVBiPkPJT9uHLdOKfFpqcfprwBFOAAXXozRubr7R7PfIuKvQlzcI4M0pALeqXN09vdaMbUdUj+pass+uULAg==" "integrity": "sha512-k8TVBiPkPJT9uHLdOKfFpqcfprwBFOAAXXozRubr7R7PfIuKvQlzcI4M0pALeqXN09vdaMbUdUj+pass+uULAg=="
}, },
@ -4458,64 +4341,34 @@
"is-symbol" "is-symbol"
] ]
}, },
"[email protected]": {
"integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==",
"dependencies": [
"@esbuild/[email protected]",
"@esbuild/[email protected]",
"@esbuild/[email protected]",
"@esbuild/[email protected]",
"@esbuild/[email protected]",
"@esbuild/[email protected]",
"@esbuild/[email protected]",
"@esbuild/[email protected]",
"@esbuild/[email protected]",
"@esbuild/[email protected]",
"@esbuild/[email protected]",
"@esbuild/[email protected]",
"@esbuild/[email protected]",
"@esbuild/[email protected]",
"@esbuild/[email protected]",
"@esbuild/[email protected]",
"@esbuild/[email protected]",
"@esbuild/[email protected]",
"@esbuild/[email protected]",
"@esbuild/[email protected]",
"@esbuild/[email protected]",
"@esbuild/[email protected]",
"@esbuild/[email protected]",
"@esbuild/[email protected]",
"@esbuild/[email protected]"
]
},
"[email protected]": { "[email protected]": {
"integrity": "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==", "integrity": "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==",
"dependencies": [ "dependencies": [
"@esbuild/aix-ppc64@0.25.0", "@esbuild/aix-ppc64",
"@esbuild/android-arm@0.25.0", "@esbuild/android-arm",
"@esbuild/android-arm64@0.25.0", "@esbuild/android-arm64",
"@esbuild/android-x64@0.25.0", "@esbuild/android-x64",
"@esbuild/darwin-arm64@0.25.0", "@esbuild/darwin-arm64",
"@esbuild/darwin-x64@0.25.0", "@esbuild/darwin-x64",
"@esbuild/freebsd-arm64@0.25.0", "@esbuild/freebsd-arm64",
"@esbuild/freebsd-x64@0.25.0", "@esbuild/freebsd-x64",
"@esbuild/linux-arm@0.25.0", "@esbuild/linux-arm",
"@esbuild/linux-arm64@0.25.0", "@esbuild/linux-arm64",
"@esbuild/linux-ia32@0.25.0", "@esbuild/linux-ia32",
"@esbuild/linux-loong64@0.25.0", "@esbuild/linux-loong64",
"@esbuild/linux-mips64el@0.25.0", "@esbuild/linux-mips64el",
"@esbuild/linux-ppc64@0.25.0", "@esbuild/linux-ppc64",
"@esbuild/linux-riscv64@0.25.0", "@esbuild/linux-riscv64",
"@esbuild/linux-s390x@0.25.0", "@esbuild/linux-s390x",
"@esbuild/linux-x64@0.25.0", "@esbuild/linux-x64",
"@esbuild/netbsd-arm64@0.25.0", "@esbuild/netbsd-arm64",
"@esbuild/netbsd-x64@0.25.0", "@esbuild/netbsd-x64",
"@esbuild/openbsd-arm64@0.25.0", "@esbuild/openbsd-arm64",
"@esbuild/openbsd-x64@0.25.0", "@esbuild/openbsd-x64",
"@esbuild/sunos-x64@0.25.0", "@esbuild/sunos-x64",
"@esbuild/win32-arm64@0.25.0", "@esbuild/win32-arm64",
"@esbuild/win32-ia32@0.25.0", "@esbuild/win32-ia32",
"@esbuild/win32-x64@0.25.0" "@esbuild/win32-x64"
] ]
}, },
"[email protected]": { "[email protected]": {
@ -4701,12 +4554,6 @@
"get-intrinsic" "get-intrinsic"
] ]
}, },
"[email protected]": {
"integrity": "sha512-kGzZ3LWWQcGIAmg6iWvXn0ei6WDtV26wzHRMwDSzmAbcXrTEXxHy6IehI6/4eT6VRKyMP1eF1VqwrVUmE/LR7A==",
"dependencies": [
"resolve-pkg-maps"
]
},
"[email protected]": { "[email protected]": {
"integrity": "sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA==" "integrity": "sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA=="
}, },
@ -5139,9 +4986,6 @@
"[email protected]": { "[email protected]": {
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==" "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="
}, },
"[email protected]": {
"integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="
},
"[email protected]": { "[email protected]": {
"integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==" "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A=="
}, },
@ -5340,9 +5184,6 @@
"[email protected]": { "[email protected]": {
"integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==" "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg=="
}, },
"[email protected]": {
"integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="
},
"[email protected]": { "[email protected]": {
"integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==" "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="
}, },
@ -5607,9 +5448,6 @@
"[email protected]": { "[email protected]": {
"integrity": "sha512-Q+/tYsFU9r7xoOJ+y/ZTtdVQwTWfzjbiXBDMM/JKUux3+QPP02iUuIoeBQ+Ot6oEDlC+/PGjB/5A3K7KKb7hcw==" "integrity": "sha512-Q+/tYsFU9r7xoOJ+y/ZTtdVQwTWfzjbiXBDMM/JKUux3+QPP02iUuIoeBQ+Ot6oEDlC+/PGjB/5A3K7KKb7hcw=="
}, },
"[email protected]": {
"integrity": "sha512-KJhO7LBFTjP71d83trW+Ilnjbo+ySsaAgCfXOXUlmGzJ4ygYPWmysm77yg4emwfmoz3b22yvH5IsVFHbhUaH5w=="
},
"[email protected]": { "[email protected]": {
"integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==" "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg=="
}, },
@ -5777,31 +5615,6 @@
"use-sidecar" "use-sidecar"
] ]
}, },
"[email protected][email protected][email protected][email protected][email protected]": {
"integrity": "sha512-+6Gvu9b0UMmzV0JkigA7Y2YcjQABiNrweP9l9j8nrutN5OAYLRe4JgfwiUohPFngMD+Y6I5N0kW+okXhvVLGUw==",
"dependencies": [
"@babel/core",
"@babel/generator",
"@babel/types",
"@clack/core",
"@clack/prompts",
"@pivanov/utils",
"@preact/signals",
"@rollup/[email protected][email protected]",
"@types/[email protected]",
"bippy",
"[email protected]",
"[email protected]",
"kleur",
"mri",
"playwright",
"preact",
"react",
"react-dom",
"tsx",
"unplugin"
]
},
"[email protected]_@[email protected][email protected]": { "[email protected]_@[email protected][email protected]": {
"integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
"dependencies": [ "dependencies": [
@ -5912,9 +5725,6 @@
"[email protected]": { "[email protected]": {
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="
}, },
"[email protected]": {
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="
},
"[email protected]": { "[email protected]": {
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
"dependencies": [ "dependencies": [
@ -6153,9 +5963,6 @@
"totalist" "totalist"
] ]
}, },
"[email protected]": {
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="
},
"[email protected]": { "[email protected]": {
"integrity": "sha512-hNj1/oZ7ygsfmPZ7ZfN5MUBRoGg1gtpnImuJBgLO0ljQ67DtJuiQaiYdS4lUA6s0KCwnPhGivtC/WRwIZLkHyg==" "integrity": "sha512-hNj1/oZ7ygsfmPZ7ZfN5MUBRoGg1gtpnImuJBgLO0ljQ67DtJuiQaiYdS4lUA6s0KCwnPhGivtC/WRwIZLkHyg=="
}, },
@ -6516,14 +6323,6 @@
"[email protected]": { "[email protected]": {
"integrity": "sha512-oDWuGVONxhVEBtschLf2cs/Jy8i7h1T+CpdkTNWQgdAF7DhRo2G8vMCgILKe7ojdEkLhICWgI1LYSSKaJsRgcw==" "integrity": "sha512-oDWuGVONxhVEBtschLf2cs/Jy8i7h1T+CpdkTNWQgdAF7DhRo2G8vMCgILKe7ojdEkLhICWgI1LYSSKaJsRgcw=="
}, },
"[email protected]": {
"integrity": "sha512-4H8vUNGNjQ4V2EOoGw005+c+dGuPSnhpPBPHBtsZdGZBk/iJb4kguGlPWaZTZ3q5nMtFOEsY0nRDlh9PJyd6SQ==",
"dependencies": [
"[email protected]",
"[email protected]",
"get-tsconfig"
]
},
"[email protected]": { "[email protected]": {
"integrity": "sha512-C3TaO7K81YvjCgQH9Q1S3R3P3BtN3RIM8n+OvX4il1K1zgE8ZhI0op7kClgkxtutIE8hQrcrHBXvIheqKUUCxw==" "integrity": "sha512-C3TaO7K81YvjCgQH9Q1S3R3P3BtN3RIM8n+OvX4il1K1zgE8ZhI0op7kClgkxtutIE8hQrcrHBXvIheqKUUCxw=="
}, },
@ -6601,9 +6400,6 @@
"which-boxed-primitive" "which-boxed-primitive"
] ]
}, },
"[email protected]": {
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw=="
},
"[email protected]": { "[email protected]": {
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==" "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="
}, },
@ -6644,13 +6440,6 @@
"[email protected]": { "[email protected]": {
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==" "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="
}, },
"[email protected]": {
"integrity": "sha512-us4j03/499KhbGP8BU7Hrzrgseo+KdfJYWcbcajCOqsAyb8Gk0Yn2kiUIcZISYCb1JFaZfIuG3b42HmguVOKCQ==",
"dependencies": [
"acorn",
"webpack-virtual-modules"
]
},
"[email protected]": { "[email protected]": {
"integrity": "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==" "integrity": "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg=="
}, },
@ -6747,8 +6536,8 @@
"[email protected]_@[email protected]": { "[email protected]_@[email protected]": {
"integrity": "sha512-7dPxoo+WsT/64rDcwoOjk76XHj+TqNTIvHKcuMQ1k4/SeHDaQt5GFAeLYzrimZrMpn/O6DtdI03WUjdxuPM0oQ==", "integrity": "sha512-7dPxoo+WsT/64rDcwoOjk76XHj+TqNTIvHKcuMQ1k4/SeHDaQt5GFAeLYzrimZrMpn/O6DtdI03WUjdxuPM0oQ==",
"dependencies": [ "dependencies": [
"@types/node@22.13.8", "@types/node",
"esbuild@0.25.0", "esbuild",
"[email protected]", "[email protected]",
"postcss", "postcss",
"[email protected]" "[email protected]"
@ -6757,7 +6546,7 @@
"[email protected]_@[email protected][email protected][email protected]__@[email protected]_@[email protected][email protected][email protected]___@[email protected][email protected]___@[email protected][email protected][email protected][email protected][email protected]_____@[email protected][email protected]_____@[email protected][email protected]____@[email protected][email protected][email protected][email protected]____@[email protected][email protected][email protected][email protected][email protected][email protected]___@[email protected][email protected]___@[email protected]__@[email protected][email protected][email protected]": { "[email protected]_@[email protected][email protected][email protected]__@[email protected]_@[email protected][email protected][email protected]___@[email protected][email protected]___@[email protected][email protected][email protected][email protected][email protected]_____@[email protected][email protected]_____@[email protected][email protected]____@[email protected][email protected][email protected][email protected]____@[email protected][email protected][email protected][email protected][email protected][email protected]___@[email protected][email protected]___@[email protected]__@[email protected][email protected][email protected]": {
"integrity": "sha512-dfqAsNqRGUc8hB9OVR2P0w8PZPEckti2+5rdZip0WIz9WW0MnImJ8XiR61QhqLa92EQzKP2uPkzenKOAHyEIbA==", "integrity": "sha512-dfqAsNqRGUc8hB9OVR2P0w8PZPEckti2+5rdZip0WIz9WW0MnImJ8XiR61QhqLa92EQzKP2uPkzenKOAHyEIbA==",
"dependencies": [ "dependencies": [
"@types/node@22.13.8", "@types/node",
"@vitest/browser", "@vitest/browser",
"@vitest/expect", "@vitest/expect",
"@vitest/[email protected][email protected]__@[email protected]_@[email protected][email protected][email protected]__@[email protected][email protected]", "@vitest/[email protected][email protected]__@[email protected]_@[email protected][email protected][email protected]__@[email protected][email protected]",
@ -6799,9 +6588,6 @@
"[email protected]": { "[email protected]": {
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==" "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="
}, },
"[email protected]": {
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="
},
"[email protected]": { "[email protected]": {
"integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==" "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="
}, },
@ -7151,7 +6937,6 @@
"npm:react-hook-form@^7.54.2", "npm:react-hook-form@^7.54.2",
"npm:[email protected]", "npm:[email protected]",
"npm:react-qrcode-logo@3", "npm:react-qrcode-logo@3",
"npm:react-scan@~0.2.8",
"npm:react@19", "npm:react@19",
"npm:rfc4648@^1.5.4", "npm:rfc4648@^1.5.4",
"npm:simple-git-hooks@^2.11.1", "npm:simple-git-hooks@^2.11.1",

3
package.json

@ -12,7 +12,6 @@
"format": "deno fmt src/", "format": "deno fmt src/",
"dev": "deno task dev:ui", "dev": "deno task dev:ui",
"dev:ui": "deno run -A npm:vite dev", "dev:ui": "deno run -A npm:vite dev",
"dev:scan": "VITE_DEBUG_SCAN=true deno task dev:ui",
"test": "deno run -A npm:vitest", "test": "deno run -A npm:vitest",
"preview": "deno run -A npm:vite preview", "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/ ." "package": "gzipper c -i html,js,css,png,ico,svg,webmanifest,txt dist dist/output && tar -cvf dist/build.tar -C ./dist/output/ ."
@ -72,10 +71,8 @@
"react-hook-form": "^7.54.2", "react-hook-form": "^7.54.2",
"react-map-gl": "8.0.1", "react-map-gl": "8.0.1",
"react-qrcode-logo": "^3.0.0", "react-qrcode-logo": "^3.0.0",
"react-scan": "^0.2.8",
"rfc4648": "^1.5.4", "rfc4648": "^1.5.4",
"vite-plugin-node-polyfills": "^0.23.0", "vite-plugin-node-polyfills": "^0.23.0",
"zustand": "5.0.3" "zustand": "5.0.3"
}, },
"devDependencies": { "devDependencies": {

9
src/components/Dialog/DialogManager.tsx

@ -6,8 +6,9 @@ import { PkiBackupDialog } from "@components/Dialog/PKIBackupDialog.tsx";
import { QRDialog } from "@components/Dialog/QRDialog.tsx"; import { QRDialog } from "@components/Dialog/QRDialog.tsx";
import { RebootDialog } from "@components/Dialog/RebootDialog.tsx"; import { RebootDialog } from "@components/Dialog/RebootDialog.tsx";
import { ShutdownDialog } from "@components/Dialog/ShutdownDialog.tsx"; import { ShutdownDialog } from "@components/Dialog/ShutdownDialog.tsx";
import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog.tsx"; import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx";
import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx"; import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx";
import { RefreshKeysDialog } from "@components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx";
export const DialogManager = () => { export const DialogManager = () => {
const { channels, config, dialog, setDialogOpen } = useDevice(); const { channels, config, dialog, setDialogOpen } = useDevice();
@ -70,6 +71,12 @@ export const DialogManager = () => {
setDialogOpen("unsafeRoles", open); setDialogOpen("unsafeRoles", open);
}} }}
/> />
<RefreshKeysDialog
open={dialog.refreshKeys}
onOpenChange={(open) => {
setDialogOpen("refreshKeys", open);
}}
/>
</> </>
); );
}; };

12
src/components/Dialog/NewDeviceDialog.tsx

@ -53,7 +53,7 @@ const links: { [key: string]: string } = {
const listFormatter = new Intl.ListFormat("en", { const listFormatter = new Intl.ListFormat("en", {
style: "long", style: "long",
type: "conjunction", type: "disjunction",
}); });
const ErrorMessage = ({ missingFeatures }: FeatureErrorProps) => { const ErrorMessage = ({ missingFeatures }: FeatureErrorProps) => {
@ -79,16 +79,16 @@ const ErrorMessage = ({ missingFeatures }: FeatureErrorProps) => {
}; };
return ( return (
<Subtle className="flex flex-col items-start gap-2 text-slate-900 bg-red-200/80 p-4 rounded-md"> <Subtle className="flex flex-col items-start gap-2 bg-red-500 p-4 rounded-md">
<div className="flex items-center gap-2 w-full"> <div className="flex items-center gap-2 w-full">
<AlertCircle size={40} className="mr-2 shrink-0" /> <AlertCircle size={40} className="mr-2 shrink-0 text-white" />
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<p className="text-sm"> <p className="text-sm text-white">
{browserFeatures.length > 0 && ( {browserFeatures.length > 0 && (
<> <>
This application requires{" "} This connection type requires{" "}
{formatFeatureList(browserFeatures)}. Please use a {formatFeatureList(browserFeatures)}. Please use a
Chromium-based browser like Chrome or Edge. supported browser, like Chrome or Edge.
</> </>
)} )}
{needsSecureContext && ( {needsSecureContext && (

190
src/components/Dialog/NodeDetailsDialog.tsx

@ -1,190 +0,0 @@
import { useAppStore } from "../../core/stores/appStore.ts";
import { useDevice } from "../../core/stores/deviceStore.ts";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "../UI/Accordion.tsx";
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../UI/Dialog.tsx";
import { Protobuf } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { DeviceImage } from "../generic/DeviceImage.tsx";
import { TimeAgo } from "../generic/TimeAgo.tsx";
import { Uptime } from "../generic/Uptime.tsx";
export interface NodeDetailsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const NodeDetailsDialog = ({
open,
onOpenChange,
}: NodeDetailsDialogProps) => {
const { nodes } = useDevice();
const { nodeNumDetails } = useAppStore();
const device: Protobuf.Mesh.NodeInfo = nodes.get(nodeNumDetails);
return device
? (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>
Node Details for {device.user?.longName ?? "UNKNOWN"} (
{device.user?.shortName ?? "UNK"})
</DialogTitle>
</DialogHeader>
<DialogFooter>
<div className="w-full">
<DeviceImage
className="w-32 h-32 mx-auto rounded-lg border-4 border-slate-200 dark:border-slate-800"
deviceType={Protobuf.Mesh
.HardwareModel[device.user?.hwModel ?? 0]}
/>
<div className="bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
<p className="text-lg font-semibold text-slate-900 dark:text-slate-50">
Details:
</p>
<p>
Hardware:{" "}
{Protobuf.Mesh.HardwareModel[device.user?.hwModel ?? 0]}
</p>
<p>Node Number: {device.num}</p>
<p>Node HEX: !{numberToHexUnpadded(device.num)}</p>
<p>
Role: {Protobuf.Config.Config_DeviceConfig_Role[
device.user?.role ?? 0
]}
</p>
<p>
Last Heard: {device.lastHeard === 0
? (
"Never"
)
: <TimeAgo timestamp={device.lastHeard * 1000} />}
</p>
</div>
{device.position
? (
<div className="mt-5 bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
<p className="text-lg font-semibold text-slate-900 dark:text-slate-50">
Position:
</p>
{device.position.latitudeI && device.position.longitudeI
? (
<p>
Coordinates:{" "}
<a
className="text-blue-500 dark:text-blue-400"
href={`https://www.openstreetmap.org/?mlat=${device.position.latitudeI / 1e7
}&mlon=${device.position.longitudeI / 1e7
}&layers=N`}
target="_blank"
rel="noreferrer"
>
{device.position.latitudeI / 1e7},{" "}
{device.position.longitudeI / 1e7}
</a>
</p>
)
: null}
{device.position.altitude
? <p>Altitude: {device.position.altitude}m</p>
: null}
</div>
)
: null}
{device.deviceMetrics
? (
<div className="mt-5 bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
<p className="text-lg font-semibold text-slate-900 dark:text-slate-50">
Device Metrics:
</p>
{device.deviceMetrics.airUtilTx
? (
<p>
Air TX utilization:{" "}
{device.deviceMetrics.airUtilTx.toFixed(2)}%
</p>
)
: null}
{device.deviceMetrics.channelUtilization
? (
<p>
Channel utilization:{" "}
{device.deviceMetrics.channelUtilization.toFixed(2)}%
</p>
)
: null}
{device.deviceMetrics.batteryLevel
? (
<p>
Battery level:{" "}
{device.deviceMetrics.batteryLevel.toFixed(2)}%
</p>
)
: null}
{device.deviceMetrics.voltage
? (
<p>
Voltage: {device.deviceMetrics.voltage.toFixed(2)}V
</p>
)
: null}
{device.deviceMetrics.uptimeSeconds
? (
<p>
Uptime:{" "}
<Uptime
seconds={device.deviceMetrics.uptimeSeconds}
/>
</p>
)
: null}
</div>
)
: null}
{device
? (
<div className="mt-5 w-full max-w-[464px] bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
<Accordion
className="AccordionRoot"
type="single"
collapsible
>
<AccordionItem className="AccordionItem" value="item-1">
<AccordionTrigger>
<p className="text-lg font-semibold text-slate-900 dark:text-slate-50">
All Raw Metrics:
</p>
</AccordionTrigger>
<AccordionContent className="overflow-x-scroll">
<pre className="text-xs w-full">
{JSON.stringify(device, null, 2)}
</pre>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
)
: null}
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
: null;
};

73
src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.test.tsx

@ -0,0 +1,73 @@
import { describe, it, vi, expect, beforeEach, Mock } 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";
vi.mock("@core/stores/deviceStore");
vi.mock("@core/stores/appStore");
describe("NodeDetailsDialog", () => {
const mockDevice = {
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,
},
};
beforeEach(() => {
// Reset mocks before each test
vi.resetAllMocks();
(useDevice as Mock).mockReturnValue({
nodes: new Map([[1234, mockDevice]]),
});
(useAppStore as unknown as Mock).mockReturnValue({
nodeNumDetails: 1234,
});
});
it("renders node details correctly", () => {
render(<NodeDetailsDialog open={true} onOpenChange={() => { }} />);
expect(screen.getByText(/Node Details for Test Node/i)).toBeInTheDocument();
expect(screen.getByText("Node Number: 1234")).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(/Coordinates:/i)).toBeInTheDocument();
expect(screen.getByText("45, -75")).toBeInTheDocument();
expect(screen.getByText(/Altitude: 200m/i)).toBeInTheDocument();
expect(screen.getByText(/Role:/i)).toBeInTheDocument();
});
it("renders null if device is not found", () => {
(useDevice as Mock).mockReturnValue({
nodes: new Map(),
});
render(<NodeDetailsDialog open={true} onOpenChange={() => { }} />);
expect(screen.queryByText(/Node Details for/i)).not.toBeInTheDocument();
});
});

177
src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx

@ -0,0 +1,177 @@
import { useAppStore } from "@core/stores/appStore.ts";
import { useDevice } from "@core/stores/deviceStore.ts";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@components/UI/Accordion.tsx";
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { Protobuf } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { DeviceImage } from "@components/generic/DeviceImage.tsx";
import { TimeAgo } from "@components/generic/TimeAgo.tsx";
import { Uptime } from "@components/generic/Uptime.tsx";
export interface NodeDetailsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const NodeDetailsDialog = ({
open,
onOpenChange,
}: NodeDetailsDialogProps) => {
const { nodes } = useDevice();
const { nodeNumDetails } = useAppStore();
const device = nodes.get(nodeNumDetails);
if (!device) return null;
const deviceMetricsMap = [
{
key: "airUtilTx",
label: "Air TX utilization",
value: device.deviceMetrics?.airUtilTx,
format: (val: number) => `${val.toFixed(2)}%`,
},
{
key: "channelUtilization",
label: "Channel utilization",
value: device.deviceMetrics?.channelUtilization,
format: (val: number) => `${val.toFixed(2)}%`,
},
{
key: "batteryLevel",
label: "Battery level",
value: device.deviceMetrics?.batteryLevel,
format: (val: number) => `${val.toFixed(2)}%`,
},
{
key: "voltage",
label: "Voltage",
value: device.deviceMetrics?.voltage,
format: (val: number) => `${val.toFixed(2)}V`,
},
];
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent >
<DialogClose />
<DialogHeader>
<DialogTitle>
Node Details for {device.user?.longName ?? "UNKNOWN"} (
{device.user?.shortName ?? "UNK"})
</DialogTitle>
</DialogHeader>
<DialogFooter>
<div className="w-full">
<div className="flex flex-col">
<DeviceImage
className="w-32 h-32 mx-auto rounded-lg border-4 border-slate-200 dark:border-slate-800"
deviceType={
Protobuf.Mesh.HardwareModel[device.user?.hwModel ?? 0]
}
/>
<div className="bg-slate-100 text-slate-900 dark:text-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
<p className="text-lg font-semibold">Details:</p>
<p>
Hardware:{" "}
{Protobuf.Mesh.HardwareModel[device.user?.hwModel ?? 0]}
</p>
<p>Node Number: {device.num}</p>
<p>Node Hex: !{numberToHexUnpadded(device.num)}</p>
<p>
Role:{" "}
{
Protobuf.Config.Config_DeviceConfig_Role[
device.user?.role ?? 0
]
}
</p>
<p>
Last Heard:{" "}
{device.lastHeard === 0 ? "Never" : <TimeAgo timestamp={device.lastHeard * 1000} />}
</p>
</div>
{device.position && (
<div className="text-slate-900 dark:text-slate-100 bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
<p className="text-lg font-semibold">Position:</p>
{device.position.latitudeI && device.position.longitudeI && (
<p>
Coordinates:{" "}
<a
className="text-blue-500 dark:text-blue-400"
href={`https://www.openstreetmap.org/?mlat=${device.position.latitudeI / 1e7
}&mlon=${device.position.longitudeI / 1e7
}&layers=N`}
target="_blank"
rel="noreferrer"
>
{device.position.latitudeI / 1e7},{" "}
{device.position.longitudeI / 1e7}
</a>
</p>
)}
{device.position.altitude && (
<p>Altitude: {device.position.altitude}m</p>
)}
</div>
)}
{device.deviceMetrics && (
<div className="text-slate-900 dark:text-slate-100 bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
<p className="text-lg font-semibold text-slate-900 dark:text-slate-50">
Device Metrics:
</p>
{deviceMetricsMap.map(
(metric) =>
metric.value !== undefined && (
<p key={metric.key}>
{metric.label}: {metric.format(metric.value)}
</p>
)
)}
{device.deviceMetrics.uptimeSeconds && (
<p>
Uptime:{" "}
<Uptime seconds={device.deviceMetrics.uptimeSeconds} />
</p>
)}
</div>
)}
</div>
<div className="text-slate-900 dark:text-slate-100 w-full max-w-[464px] bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
<Accordion className="AccordionRoot" type="single" collapsible>
<AccordionItem className="AccordionItem" value="item-1">
<AccordionTrigger>
<p className="text-lg font-semibold text-slate-900 dark:text-slate-50">
All Raw Metrics:
</p>
</AccordionTrigger>
<AccordionContent className="overflow-x-scroll">
<pre className="text-xs w-full">
{JSON.stringify(device, null, 2)}
</pre>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

55
src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.test.tsx

@ -0,0 +1,55 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { beforeEach, describe, expect, it, Mock, vi } from "vitest";
import { RefreshKeysDialog } from "./RefreshKeysDialog";
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
vi.mock("./useRefreshKeysDialog.ts", () => ({
useRefreshKeysDialog: vi.fn(),
}));
describe("RefreshKeysDialog Component", () => {
let handleCloseDialogMock: Mock;
let handleNodeRemoveMock: Mock;
let onOpenChangeMock: Mock;
beforeEach(() => {
handleCloseDialogMock = vi.fn();
handleNodeRemoveMock = vi.fn();
onOpenChangeMock = vi.fn();
(useRefreshKeysDialog as Mock).mockReturnValue({
handleCloseDialog: handleCloseDialogMock,
handleNodeRemove: handleNodeRemoveMock,
});
});
it("renders the dialog with correct content", () => {
render(<RefreshKeysDialog open={true} onOpenChange={onOpenChangeMock} />);
expect(screen.getByText("Keys Mismatch")).toBeInTheDocument();
expect(screen.getByText("Request New Keys")).toBeInTheDocument();
expect(screen.getByText("Dismiss")).toBeInTheDocument();
});
it("calls handleNodeRemove when 'Request New Keys' button is clicked", () => {
render(<RefreshKeysDialog open={true} onOpenChange={onOpenChangeMock} />);
fireEvent.click(screen.getByText("Request New Keys"));
expect(handleNodeRemoveMock).toHaveBeenCalled();
});
it("calls handleCloseDialog when 'Dismiss' button is clicked", () => {
render(<RefreshKeysDialog open={true} onOpenChange={onOpenChangeMock} />);
fireEvent.click(screen.getByText("Dismiss"));
expect(handleCloseDialogMock).toHaveBeenCalled();
});
it("calls onOpenChange when dialog close button is clicked", () => {
render(<RefreshKeysDialog open={true} onOpenChange={onOpenChangeMock} />);
fireEvent.click(screen.getByRole("button", { name: /close/i }));
expect(handleCloseDialogMock).toHaveBeenCalled();
});
it("does not render when open is false", () => {
render(<RefreshKeysDialog open={false} onOpenChange={onOpenChangeMock} />);
expect(screen.queryByText("Keys Mismatch")).not.toBeInTheDocument();
});
});

61
src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx

@ -0,0 +1,61 @@
import {
Dialog,
DialogClose,
DialogContent,
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { Button } from "@components/UI/Button.tsx";
import { LockKeyholeOpenIcon } from "lucide-react";
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
export interface RefreshKeysDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const RefreshKeysDialog = ({ open, onOpenChange }: RefreshKeysDialogProps) => {
const { handleCloseDialog, handleNodeRemove } = useRefreshKeysDialog();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-8 flex flex-col gap-2">
<DialogClose onClick={handleCloseDialog} />
<DialogHeader>
<DialogTitle>Keys Mismatch</DialogTitle>
</DialogHeader>
Your node is unable to send a direct message to this node. This is due to the remote node's current public key not matching the previously stored key for this node.
<ul className="mt-2">
<li className="flex place-items-center gap-2 items-start">
<div className="p-2 bg-slate-500 rounded-lg mt-1">
<LockKeyholeOpenIcon size={30} className="text-white justify-center" />
</div>
<div className="flex flex-col gap-2">
<div>
<p className="font-bold mb-0.5">Accept New Keys</p>
<p>
This will remove the node from device and request new keys.
</p>
</div>
<Button
variant="default"
onClick={handleNodeRemove}
className=""
>
Request New Keys
</Button>
<Button
variant="outline"
onClick={handleCloseDialog}
className=""
>
Dismiss
</Button>
</div>
</li>
</ul>
{/* </DialogDescription> */}
</DialogContent>
</Dialog >
);
};

77
src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.test.ts

@ -0,0 +1,77 @@
import { renderHook, act } 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";
vi.mock("@core/stores/appStore.ts", () => ({
useAppStore: vi.fn(() => ({ activeChat: "chat-123" })),
}));
vi.mock("@core/stores/deviceStore.ts", () => ({
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(() => {
removeNodeMock = vi.fn();
setDialogOpenMock = vi.fn();
getNodeErrorMock = vi.fn();
clearNodeErrorMock = vi.fn();
(useDevice as Mock).mockReturnValue({
removeNode: removeNodeMock,
setDialogOpen: setDialogOpenMock,
getNodeError: getNodeErrorMock,
clearNodeError: clearNodeErrorMock,
});
});
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).toHaveBeenCalledWith("chat-123");
expect(clearNodeErrorMock).toHaveBeenCalledWith("chat-123");
expect(removeNodeMock).toHaveBeenCalledWith("node-abc");
expect(setDialogOpenMock).toHaveBeenCalledWith("refreshKeys", false);
});
it("handleNodeRemove should do nothing if there is no error", () => {
getNodeErrorMock.mockReturnValue(undefined);
const { result } = renderHook(() => useRefreshKeysDialog());
act(() => {
result.current.handleNodeRemove();
});
expect(removeNodeMock).not.toHaveBeenCalled();
expect(setDialogOpenMock).not.toHaveBeenCalled();
expect(clearNodeErrorMock).not.toHaveBeenCalled();
});
it("handleCloseDialog should close the dialog", () => {
const { result } = renderHook(() => useRefreshKeysDialog());
act(() => {
result.current.handleCloseDialog();
});
expect(setDialogOpenMock).toHaveBeenCalledWith("refreshKeys", false);
});
});

28
src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.ts

@ -0,0 +1,28 @@
import { useCallback } from "react";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDevice } from "@core/stores/deviceStore.ts";
export function useRefreshKeysDialog() {
const { removeNode, setDialogOpen, clearNodeError, getNodeError } = useDevice();
const { activeChat } = useAppStore();
const handleNodeRemove = useCallback(() => {
const nodeWithError = getNodeError(activeChat);
if (!nodeWithError) {
return;
}
clearNodeError(activeChat);
handleCloseDialog();;
return removeNode(nodeWithError?.node);
}, [activeChat, clearNodeError, setDialogOpen, removeNode]);
const handleCloseDialog = useCallback(() => {
setDialogOpen('refreshKeys', false);
}, [setDialogOpen])
return {
handleCloseDialog,
handleNodeRemove
};
}

2
src/components/Form/DynamicForm.tsx

@ -124,7 +124,7 @@ export function DynamicForm<T extends FieldValues>({
})} })}
</div> </div>
))} ))}
{hasSubmitButton && <Button type="submit">Submit</Button>} {hasSubmitButton && <Button type="submit" variant="outline">Submit</Button>}
</form> </form>
); );
} }

64
src/components/PageComponents/Channel.tsx

@ -34,12 +34,8 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
settings: { settings: {
...data.settings, ...data.settings,
psk: toByteArray(pass), psk: toByteArray(pass),
moduleSettings: { moduleSettings: {...data.settings.moduleSettings,
positionPrecision: data.settings.positionEnabled positionPrecision: data.settings.moduleSettings.positionPrecision,
? data.settings.preciseLocation
? 32
: data.settings.positionPrecision
: 0,
}, },
}, },
}); });
@ -100,17 +96,9 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
settings: { settings: {
...channel?.settings, ...channel?.settings,
psk: pass, psk: pass,
positionEnabled: moduleSettings: {...channel?.settings?.moduleSettings,
channel?.settings?.moduleSettings?.positionPrecision !== positionPrecision: channel?.settings?.moduleSettings?.positionPrecision === undefined ? 10 : channel?.settings?.moduleSettings?.positionPrecision,
undefined && }
channel?.settings?.moduleSettings?.positionPrecision > 0,
preciseLocation:
channel?.settings?.moduleSettings?.positionPrecision === 32,
positionPrecision:
channel?.settings?.moduleSettings?.positionPrecision ===
undefined
? 10
: channel?.settings?.moduleSettings?.positionPrecision,
}, },
}, },
}} }}
@ -174,39 +162,30 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
label: "Downlink Enabled", label: "Downlink Enabled",
description: "Send messages from MQTT to the local mesh", description: "Send messages from MQTT to the local mesh",
}, },
{
type: "toggle",
name: "settings.positionEnabled",
label: "Allow Position Requests",
description: "Send position to channel",
},
{
type: "toggle",
name: "settings.preciseLocation",
label: "Precise Location",
description: "Send precise location to channel",
},
{ {
type: "select", type: "select",
name: "settings.positionPrecision", name: "settings.moduleSettings.positionPrecision",
label: "Approximate Location", label: "Location",
description: description:
"If not sharing precise location, position shared on channel will be accurate within this distance", "The precision of the location to share with the channel. Can be disabled.",
properties: { properties: {
enumValue: config.display?.units === 0 enumValue: config.display?.units === 0
? { ? {
"Within 23 km": 10, "Do not share location": 0,
"Within 12 km": 11, "Within 23 kilometers": 10,
"Within 5.8 km": 12, "Within 12 kilometers": 11,
"Within 2.9 km": 13, "Within 5.8 kilometers": 12,
"Within 1.5 km": 14, "Within 2.9 kilometers": 13,
"Within 700 m": 15, "Within 1.5 kilometers": 14,
"Within 350 m": 16, "Within 700 meters": 15,
"Within 200 m": 17, "Within 350 meters": 16,
"Within 90 m": 18, "Within 200 meters": 17,
"Within 50 m": 19, "Within 90 meters": 18,
"Within 50 meters": 19,
"Precise Location": 32,
} }
: { : {
"Do not share location": 0,
"Within 15 miles": 10, "Within 15 miles": 10,
"Within 7.3 miles": 11, "Within 7.3 miles": 11,
"Within 3.6 miles": 12, "Within 3.6 miles": 12,
@ -217,6 +196,7 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
"Within 600 feet": 17, "Within 600 feet": 17,
"Within 300 feet": 18, "Within 300 feet": 18,
"Within 150 feet": 19, "Within 150 feet": 19,
"Precise Location": 32,
}, },
}, },
}, },

6
src/components/PageComponents/Connect/Serial.tsx

@ -18,9 +18,7 @@ export const Serial = ({ closeDialog }: TabElementProps) => {
setSerialPorts(await navigator?.serial.getPorts()); setSerialPorts(await navigator?.serial.getPorts());
}, []); }, []);
navigator?.serial?.addEventListener("connect", (event) => { navigator?.serial?.addEventListener("connect", () => {
console.log(event);
updateSerialPortList(); updateSerialPortList();
}); });
navigator?.serial?.addEventListener("disconnect", () => { navigator?.serial?.addEventListener("disconnect", () => {
@ -47,8 +45,6 @@ export const Serial = ({ closeDialog }: TabElementProps) => {
<div className="flex w-full flex-col gap-2 p-4"> <div className="flex w-full flex-col gap-2 p-4">
<div className="flex h-48 flex-col gap-2 overflow-y-auto"> <div className="flex h-48 flex-col gap-2 overflow-y-auto">
{serialPorts.map((port, index) => { {serialPorts.map((port, index) => {
console.log(port);
const { usbProductId, usbVendorId } = port.getInfo(); const { usbProductId, usbVendorId } = port.getInfo();
return ( return (
<Button <Button

5
src/components/PageComponents/Messages/ChannelChat.tsx

@ -15,9 +15,10 @@ const EmptyState = () => (
); );
export const ChannelChat = ({ export const ChannelChat = ({
messages, messages = [],
}: ChannelChatProps) => { }: ChannelChatProps) => {
const { nodes } = useDevice(); const { nodes } = useDevice();
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null); const scrollContainerRef = useRef<HTMLDivElement>(null);
@ -57,7 +58,7 @@ export const ChannelChat = ({
className="flex-1 overflow-y-auto pl-4 pr-4 md:pr-44" className="flex-1 overflow-y-auto pl-4 pr-4 md:pr-44"
> >
<div className="flex flex-col justify-end min-h-full"> <div className="flex flex-col justify-end min-h-full">
{messages.map((message, index) => ( {messages?.map((message, index) => (
<Message <Message
key={message.id} key={message.id}
message={message} message={message}

122
src/components/PageComponents/Messages/Message.tsx

@ -1,3 +1,4 @@
import { memo, useMemo } from "react";
import { import {
Tooltip, Tooltip,
TooltipArrow, TooltipArrow,
@ -12,15 +13,13 @@ import {
import { cn } from "@core/utils/cn.ts"; import { cn } from "@core/utils/cn.ts";
import { Avatar } from "@components/UI/Avatar.tsx"; import { Avatar } from "@components/UI/Avatar.tsx";
import type { Protobuf } from "@meshtastic/core"; import type { Protobuf } from "@meshtastic/core";
import { AlertCircle, CheckCircle2, CircleEllipsis } from "lucide-react"; import { AlertCircle, CheckCircle2, CircleEllipsis, LucideIcon } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { useMemo } from "react";
const MESSAGE_STATES = { type MessageStateValue = {
ACK: "ack", state: string;
WAITING: "waiting", icon: LucideIcon;
FAILED: "failed", displayText: string;
} as const; }
type MessageState = MessageWithState["state"]; type MessageState = MessageWithState["state"];
@ -40,31 +39,36 @@ interface StatusIconProps {
className?: string; className?: string;
} }
const STATUS_TEXT_MAP: Record<MessageState, string> = { const MESSAGE_STATES: Record<string, MessageStateValue> = {
[MESSAGE_STATES.ACK]: "Message delivered", ACK: { state: 'ack', icon: CheckCircle2, displayText: "Message delivered" },
[MESSAGE_STATES.WAITING]: "Waiting for delivery", WAITING: { state: 'waiting', icon: CircleEllipsis, displayText: "Waiting for delivery" },
[MESSAGE_STATES.FAILED]: "Delivery failed", FAILED: { state: 'failed', icon: AlertCircle, displayText: "Delivery failed" },
};
const STATUS_ICON_MAP: Record<MessageState, LucideIcon> = {
[MESSAGE_STATES.ACK]: CheckCircle2,
[MESSAGE_STATES.WAITING]: CircleEllipsis,
[MESSAGE_STATES.FAILED]: AlertCircle,
}; };
const getStatusText = (state: MessageState): string => STATUS_TEXT_MAP[state]; const getMessageState = (state: MessageState): MessageStateValue => {
switch (state) {
case MESSAGE_STATES.ACK.state:
return MESSAGE_STATES.ACK;
case MESSAGE_STATES.WAITING.state:
return MESSAGE_STATES.WAITING;
case MESSAGE_STATES.FAILED.state:
return MESSAGE_STATES.FAILED;
default:
return MESSAGE_STATES.FAILED;
}
}
const StatusTooltip = ({ state, children }: StatusTooltipProps) => ( const StatusTooltip = ({ state, children }: StatusTooltipProps) => (
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger> <TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent <TooltipContent
className="rounded-md bg-slate-800 px-3 py-1.5 text-sm text-white shadow-md animate-in fade-in-0 zoom-in-95" className="rounded-md bg-slate-800 px-3 py-1.5 text-sm text-white dark:text-white shadow-md animate-in fade-in-0 zoom-in-95"
side="top" side="top"
align="center" align="center"
sideOffset={5} sideOffset={5}
> >
{getStatusText(state)} {getMessageState(state).displayText ?? "An unknown error occurred"};
<TooltipArrow className="fill-slate-800" /> <TooltipArrow className="fill-slate-800" />
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
@ -72,13 +76,17 @@ const StatusTooltip = ({ state, children }: StatusTooltipProps) => (
); );
const StatusIcon = ({ state, className, ...otherProps }: StatusIconProps) => { const StatusIcon = ({ state, className, ...otherProps }: StatusIconProps) => {
const isFailed = state === MESSAGE_STATES.FAILED; const msgState = getMessageState(state);
const isFailed = msgState.state === 'failed'
const iconClass = cn( const iconClass = cn(
className, className,
"text-slate-500 dark:text-slate-400 w-4 h-4 shrink-0", "text-slate-500 dark:text-slate-400 size-5 shrink-0"
); );
const Icon = STATUS_ICON_MAP[state]; const Icon = msgState.icon;
return ( return (
<StatusTooltip state={state}> <StatusTooltip state={state}>
<Icon <Icon
@ -90,23 +98,7 @@ const StatusIcon = ({ state, className, ...otherProps }: StatusIconProps) => {
); );
}; };
const getMessageTextStyles = (state: MessageState) => { const TimeDisplay = memo(({ date, className }: { date: Date; className?: string }) => (
const isAcknowledged = state === MESSAGE_STATES.ACK;
const isFailed = state === MESSAGE_STATES.FAILED;
return cn(
"break-words overflow-hidden",
isAcknowledged
? "text-slate-900 dark:text-white"
: "text-slate-900 dark:text-slate-400",
isFailed && "text-red-500 dark:text-red-500",
);
};
const TimeDisplay = ({
date,
className,
}: { date: Date; className?: string }) => (
<div className={cn("flex items-center gap-2 shrink-0", className)}> <div className={cn("flex items-center gap-2 shrink-0", className)}>
<span className="text-xs text-slate-500 dark:text-slate-400 font-mono"> <span className="text-xs text-slate-500 dark:text-slate-400 font-mono">
{date.toLocaleDateString()} {date.toLocaleDateString()}
@ -118,9 +110,9 @@ const TimeDisplay = ({
})} })}
</span> </span>
</div> </div>
); ));
export const Message = ({ lastMsgSameUser, message, sender }: MessageProps) => { export const Message = memo(({ lastMsgSameUser, message, sender }: MessageProps) => {
const { getDevices } = useDeviceStore(); const { getDevices } = useDeviceStore();
const isDeviceUser = useMemo( const isDeviceUser = useMemo(
@ -128,33 +120,47 @@ export const Message = ({ lastMsgSameUser, message, sender }: MessageProps) => {
getDevices() getDevices()
.map((device) => device.nodes.get(device.hardware.myNodeNum)?.num) .map((device) => device.nodes.get(device.hardware.myNodeNum)?.num)
.includes(message.from), .includes(message.from),
[getDevices, message.from], [getDevices, message.from]
); );
const messageUser = sender?.user; const messageUser = sender?.user;
const messageTextClass = getMessageTextStyles(message.state); const getMessageTextStyles = (state: MessageState) => {
const msgState = getMessageState(state);
const isAcknowledged = msgState.state === 'ack'
const isFailed = msgState.state === 'failed'
return cn(
"break-words overflow-hidden",
isAcknowledged
? "text-slate-900 dark:text-white"
: "text-slate-900 dark:text-slate-400",
isFailed && "text-red-500 dark:text-red-500",
);
};
const messageTextClass = useMemo(() => getMessageTextStyles(message.state), [message.state]);
return ( return (
<div className="flex flex-col w-full px-4 justify-start"> <div className="flex flex-col w-full px-4 justify-start">
<div <div
className={cn( className={cn(
"flex flex-col flex-wrap items-start py-1", "flex flex-col flex-wrap items-start py-1",
isDeviceUser && "items-end", isDeviceUser && "items-end"
)} )}
> >
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
{!lastMsgSameUser {!lastMsgSameUser && (
? ( <div className="flex place-items-center gap-2 mb-1">
<div className="flex place-items-center gap-2 mb-1"> <Avatar text={messageUser?.shortName ?? "UNK"} />
<Avatar text={messageUser?.shortName ?? "UNK"} /> <div className="flex flex-col">
<div className="flex flex-col"> <span className="font-medium text-slate-900 dark:text-white truncate">
<span className="font-medium text-slate-900 dark:text-white truncate"> {messageUser?.longName}
{messageUser?.longName} </span>
</span>
</div>
</div> </div>
) </div>
: null} )}
</div> </div>
<TimeDisplay date={message.rxTime} /> <TimeDisplay date={message.rxTime} />
<div className="flex place-items-center gap-2 pb-2"> <div className="flex place-items-center gap-2 pb-2">
@ -166,4 +172,4 @@ export const Message = ({ lastMsgSameUser, message, sender }: MessageProps) => {
</div> </div>
</div> </div>
); );
}; });

126
src/components/PageComponents/Messages/MessageItem.tsx

@ -0,0 +1,126 @@
import {
Tooltip,
TooltipArrow,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@components/UI/Tooltip.tsx";
import { useDeviceStore } from "@core/stores/deviceStore.ts";
import { cn } from "@core/utils/cn.ts";
import { Avatar } from "@components/UI/Avatar.tsx";
import { AlertCircle, CheckCircle2, CircleEllipsis } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { ReactNode, useMemo } from "react";
import { Message, MessageState } from "@core/services/types.ts";
interface MessageProps {
lastMsgSameUser: boolean;
message: Message;
}
interface MessageStatus {
state: MessageState;
displayText: string;
icon: LucideIcon;
}
const MESSAGE_STATUS: Record<MessageState, MessageStatus> = {
ack: { state: "ack", displayText: "Message delivered", icon: CheckCircle2 },
waiting: { state: "waiting", displayText: "Waiting for delivery", icon: CircleEllipsis },
failed: { state: "failed", displayText: "Delivery failed", icon: AlertCircle },
};
const getMessageStatus = (state: MessageState): MessageStatus =>
MESSAGE_STATUS[state] || { state: "failed", displayText: "Unknown error", icon: AlertCircle };
const StatusTooltip = ({ status, children }: { status: MessageStatus; children: ReactNode }) => (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent
className="rounded-md bg-slate-800 px-3 py-1.5 text-sm text-white shadow-md animate-in fade-in-0 zoom-in-95"
side="top"
align="center"
sideOffset={5}
>
{status.displayText}
<TooltipArrow className="fill-slate-800" />
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
const StatusIcon = ({ status, className, ...otherProps }: { status: MessageStatus; className?: string }) => {
const isFailed = status.state === "failed";
const iconClass = cn("text-slate-500 dark:text-slate-400 w-4 h-4 shrink-0", className);
const Icon = status.icon;
return (
<StatusTooltip status={status}>
<Icon className={iconClass} {...otherProps} color={isFailed ? "red" : "currentColor"} />
</StatusTooltip>
);
};
const getMessageTextStyles = (status: MessageStatus) => {
const isAcknowledged = status.state === "ack";
const isFailed = status.state === "failed";
return cn(
"break-words overflow-hidden",
isAcknowledged ? "text-slate-900 dark:text-white" : "text-slate-900 dark:text-slate-400",
isFailed && "text-red-500 dark:text-red-500",
);
};
const TimeDisplay = ({ date, className }: { date: Date; className?: string }) => (
<div className={cn("flex items-center gap-2 shrink-0", className)}>
<span className="text-xs text-slate-500 dark:text-slate-400 font-mono">{date.toLocaleDateString()}</span>
<span className="text-xs text-slate-500 dark:text-slate-400 font-mono">
{date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })}
</span>
</div>
);
export const MessageItem = ({ lastMsgSameUser, message }: MessageProps) => {
const { getDevices } = useDeviceStore();
const isDeviceUser = useMemo(
() =>
getDevices()
.map((device) => device.nodes.get(device.hardware.myNodeNum)?.num)
.includes(message.from),
[getDevices, message.from],
);
const messageUser = message?.from
? getDevices().find((device) => device.nodes.has(message.from))?.nodes.get(message.from)
: null;
const messageStatus = getMessageStatus(message.state);
const messageTextClass = getMessageTextStyles(messageStatus);
return (
<div className="flex flex-col w-full px-4 justify-start">
<div className={cn("flex flex-col flex-wrap items-start py-1", messageTextClass, isDeviceUser && "items-end")}>
<div className="flex items-center gap-2 mb-2">
{!lastMsgSameUser && (
<div className="flex place-items-center gap-2 mb-1">
<Avatar text={messageUser?.user?.shortName ?? "UNK"} />
<div className="flex flex-col">
<span className="font-medium text-slate-900 dark:text-white truncate">
{messageUser?.user?.longName}
</span>
</div>
</div>
)}
</div>
<TimeDisplay date={message.date} />
<div className="flex place-items-center gap-2 pb-2">
<div className={cn(isDeviceUser && "pl-11", messageTextClass)}>{message.message}</div>
<StatusIcon status={messageStatus} />
</div>
</div>
</div>
);
};

95
src/components/PageComponents/Messages/TraceRoute.test.tsx

@ -0,0 +1,95 @@
import { describe, it, expect, vi, beforeEach, Mock } from "vitest";
import { render, screen } from "@testing-library/react";
import { TraceRoute } from "@components/PageComponents/Messages/TraceRoute.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
vi.mock("@core/stores/deviceStore");
describe("TraceRoute", () => {
const mockNodes = new Map([
[
1,
{ num: 1, user: { longName: "Node A" } },
],
[
2,
{ num: 2, user: { longName: "Node B" } },
],
[
3,
{ num: 3, user: { longName: "Node C" } },
],
]);
beforeEach(() => {
vi.resetAllMocks();
(useDevice as Mock).mockReturnValue({
nodes: mockNodes,
});
});
it("renders the route to destination with SNR values", () => {
render(
<TraceRoute
from={{ user: { longName: "Source Node" } } as any}
to={{ user: { longName: "Destination Node" } } as any}
route={[1, 2]}
snrTowards={[10, 20, 30]}
/>
);
expect(screen.getByText("Route to destination:")).toBeInTheDocument();
expect(screen.getByText("Destination Node")).toBeInTheDocument();
expect(screen.getByText("Node A")).toBeInTheDocument();
expect(screen.getByText("Node B")).toBeInTheDocument();
expect(screen.getAllByText(/↓/)).toHaveLength(3); // startNode + 2 hops
expect(screen.getByText("↓ 10dB")).toBeInTheDocument();
expect(screen.getByText("↓ 20dB")).toBeInTheDocument();
expect(screen.getByText("↓ 30dB")).toBeInTheDocument();
expect(screen.getByText("Source Node")).toBeInTheDocument();
});
it("renders the route back when provided", () => {
render(
<TraceRoute
from={{ user: { longName: "Source Node" } } as any}
to={{ user: { longName: "Destination Node" } } as any}
route={[1]}
snrTowards={[15, 25]}
routeBack={[3]}
snrBack={[35, 45]}
/>
);
expect(screen.getByText("Route back:")).toBeInTheDocument();
expect(screen.getByText("Node C")).toBeInTheDocument();
expect(screen.getByText("↓ 35dB")).toBeInTheDocument();
expect(screen.getByText("↓ 45dB")).toBeInTheDocument();
});
it("renders '??' for missing SNR values", () => {
render(
<TraceRoute
from={{ user: { longName: "Source" } } as any}
to={{ user: { longName: "Dest" } } as any}
route={[1]}
/>
);
expect(screen.getAllByText("↓ ??dB").length).toBeGreaterThan(0);
});
it("renders hop hex if node is not found", () => {
render(
<TraceRoute
from={{ user: { longName: "Source" } } as any}
to={{ user: { longName: "Dest" } } as any}
route={[99]}
/>
);
expect(screen.getByText(/^!63$/)).toBeInTheDocument(); // 99 in hex
});
});

78
src/components/PageComponents/Messages/TraceRoute.tsx

@ -11,6 +11,33 @@ export interface TraceRouteProps {
snrBack?: Array<number>; snrBack?: Array<number>;
} }
interface RoutePathProps {
title: string;
startNode?: Protobuf.Mesh.NodeInfo;
endNode?: Protobuf.Mesh.NodeInfo;
path: number[];
snr?: number[];
}
const RoutePath = ({ title, startNode, endNode, path, snr }: RoutePathProps) => {
const { nodes } = useDevice();
return (
<span className="ml-4 border-l-2 border-l-background-primary pl-2 text-slate-900 dark:text-slate-900">
<p className="font-semibold">{title}</p>
<p>{startNode?.user?.longName}</p>
<p> {snr?.[0] ?? "??"}dB</p>
{path.map((hop, i) => (
<span key={nodes.get(hop)?.num ?? hop}>
<p>{nodes.get(hop)?.user?.longName ?? `!${numberToHexUnpadded(hop)}`}</p>
<p> {snr?.[i + 1] ?? "??"}dB</p>
</span>
))}
<p>{endNode?.user?.longName}</p>
</span>
);
};
export const TraceRoute = ({ export const TraceRoute = ({
from, from,
to, to,
@ -19,43 +46,24 @@ export const TraceRoute = ({
snrTowards, snrTowards,
snrBack, snrBack,
}: TraceRouteProps) => { }: TraceRouteProps) => {
const { nodes } = useDevice();
return ( return (
<div className="ml-5 flex"> <div className="ml-5 flex">
<span className="ml-4 border-l-2 border-l-background-primary pl-2 text-text-primary"> <RoutePath
<p className="font-semibold">Route to destination:</p> title="Route to destination:"
<p>{to?.user?.longName}</p> startNode={to}
<p> {snrTowards?.[0] ? snrTowards[0] : "??"}dB</p> endNode={from}
{route.map((hop, i) => ( path={route}
<span key={nodes.get(hop)?.num}> snr={snrTowards}
<p> />
{nodes.get(hop)?.user?.longName ?? `!${numberToHexUnpadded(hop)}`} {routeBack && (
</p> <RoutePath
<p> {snrTowards?.[i + 1] ? snrTowards[i + 1] : "??"}dB</p> title="Route back:"
</span> startNode={from}
))} endNode={to}
{from?.user?.longName} path={routeBack}
</span> snr={snrBack}
{routeBack />
? ( )}
<span className="ml-4 border-l-2 border-l-background-primary pl-2 text-text-primary">
<p className="font-semibold">Route back:</p>
<p>{from?.user?.longName}</p>
<p> {snrBack?.[0] ? snrBack[0] : "??"}dB</p>
{routeBack.map((hop, i) => (
<span key={nodes.get(hop)?.num}>
<p>
{nodes.get(hop)?.user?.longName ??
`!${numberToHexUnpadded(hop)}`}
</p>
<p> {snrBack?.[i + 1] ? snrBack[i + 1] : "??"}dB</p>
</span>
))}
{to?.user?.longName}
</span>
)
: null}
</div> </div>
); );
}; };

2
src/components/Sidebar.tsx

@ -58,7 +58,7 @@ export const Sidebar = ({ children }: SidebarProps) => {
page: "channels", page: "channels",
}, },
{ {
name: "Nodes", name: `Nodes (${nodes.size})`,
icon: UsersIcon, icon: UsersIcon,
page: "nodes", page: "nodes",
}, },

15
src/components/ThemeSwitcher.tsx

@ -24,18 +24,25 @@ export default function ThemeSwitcher({
setPreference(nextPreference); setPreference(nextPreference);
}; };
const [firstCharOfPreference = "", ...restOfPreference] = preference;
return ( return (
<button <button
type="button" type="button"
className={cn( className={cn(
"transition-all duration-300 scale-100 cursor-pointer m-6 p-2", "transition-all duration-300 scale-100 cursor-pointer m-6 p-2 focus:*:data-label:opacity-100",
className, className,
)} )}
onClick={toggleTheme} onClick={toggleTheme}
aria-label={preference === "system" aria-description={"Change current theme"}
? `System theme (currently ${theme}). Click to change theme.`
: `Current theme: ${theme}. Click to change theme.`}
> >
<span
data-label
className="transition-all block absolute w-full mb-auto mt-auto ml-0 mr-0 text-xs left-0 -top-5 opacity-0 rounded-lg"
>
{firstCharOfPreference.toLocaleUpperCase() +
(restOfPreference ?? []).join("")}
</span>
{themeIcons[preference]} {themeIcons[preference]}
</button> </button>
); );

11
src/components/UI/Avatar.tsx

@ -1,4 +1,5 @@
import { cn } from "../../core/utils/cn.ts"; import { cn } from "@core/utils/cn.ts";
import { LockKeyholeOpenIcon } from 'lucide-react';
import type React from "react"; import type React from "react";
type RGBColor = { type RGBColor = {
@ -12,6 +13,7 @@ interface AvatarProps {
text: string; text: string;
size?: "sm" | "lg"; size?: "sm" | "lg";
className?: string; className?: string;
showError?: boolean;
} }
// biome-ignore lint/complexity/noStaticOnlyClass: stop being annoying Biome // biome-ignore lint/complexity/noStaticOnlyClass: stop being annoying Biome
@ -43,6 +45,7 @@ class ColorUtils {
export const Avatar: React.FC<AvatarProps> = ({ export const Avatar: React.FC<AvatarProps> = ({
text, text,
size = "sm", size = "sm",
showError = false,
className, className,
}) => { }) => {
const sizes = { const sizes = {
@ -73,12 +76,11 @@ export const Avatar: React.FC<AvatarProps> = ({
return ( return (
<div <div
className={cn( className={cn(
` `flex
relative
rounded-full rounded-full
flex
items-center items-center
justify-center justify-center
size-11
font-semibold`, font-semibold`,
sizes[size], sizes[size],
className, className,
@ -88,6 +90,7 @@ export const Avatar: React.FC<AvatarProps> = ({
color: textColor, color: textColor,
}} }}
> >
{showError ? <LockKeyholeOpenIcon className="size-4 absolute bottom-0 right-0 z-10 text-red-500 stroke-3" /> : null}
<p className="p-1">{initials}</p> <p className="p-1">{initials}</p>
</div> </div>
); );

2
src/components/UI/Button.tsx

@ -15,7 +15,7 @@ const buttonVariants = cva(
success: success:
"bg-green-500 text-white hover:bg-green-600 dark:hover:bg-green-600", "bg-green-500 text-white hover:bg-green-600 dark:hover:bg-green-600",
outline: outline:
"bg-transparent border border-slate-200 hover:bg-slate-100 dark:border-slate-400 dark:text-slate-500", "bg-transparent border border-slate-400 hover:bg-slate-100 dark:border-slate-400 dark:text-slate-500",
subtle: subtle:
"bg-slate-100 text-slate-700 hover:bg-slate-200 dark:bg-slate-500 dark:text-white dark:hover:bg-slate-400", "bg-slate-100 text-slate-700 hover:bg-slate-200 dark:bg-slate-500 dark:text-white dark:hover:bg-slate-400",
ghost: ghost:

12
src/components/generic/Table/index.tsx

@ -92,7 +92,7 @@ export const Table = ({ headings, rows }: TableProps) => {
<th <th
key={heading.title} key={heading.title}
scope="col" scope="col"
className={`py-2 pr-3 pl-6 text-left ${ className={`py-2 pr-3 text-left ${
heading.sortable heading.sortable
? "cursor-pointer hover:brightness-hover active:brightness-press" ? "cursor-pointer hover:brightness-hover active:brightness-press"
: "" : ""
@ -114,8 +114,16 @@ export const Table = ({ headings, rows }: TableProps) => {
<tbody> <tbody>
{sortedRows.map((row, index) => ( {sortedRows.map((row, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: TODO: Once this table is sortable, this should get fixed. // biome-ignore lint/suspicious/noArrayIndexKey: TODO: Once this table is sortable, this should get fixed.
<tr key={index}> <tr key={index} className={`${index % 2 ? 'bg-white dark:bg-white/2' : 'bg-slate-50/50 dark:bg-slate-50/5'} border-b-1 border-slate-200 dark:border-slate-900`}>
{row.map((item, index) => ( {row.map((item, index) => (
index === 0 ?
<th
key={item.key ?? index}
className="whitespace-nowrap py-2 text-sm text-text-secondary first:pl-2"
scope="row"
>
{item}
</th> :
<td <td
key={item.key ?? index} key={item.key ?? index}
className="whitespace-nowrap py-2 text-sm text-text-secondary first:pl-2" className="whitespace-nowrap py-2 text-sm text-text-secondary first:pl-2"

57
src/core/stores/deviceStore.ts

@ -27,12 +27,18 @@ export type DialogVariant =
| "nodeRemoval" | "nodeRemoval"
| "pkiBackup" | "pkiBackup"
| "nodeDetails" | "nodeDetails"
| "unsafeRoles"; | "unsafeRoles"
| "refreshKeys";
type QueueStatus = { type QueueStatus = {
res: number, free: number, maxlen: number res: number, free: number, maxlen: number
} }
type NodeError = {
node: number;
error: string;
}
export interface Device { export interface Device {
id: number; id: number;
status: Types.DeviceStatusEnum; status: Types.DeviceStatusEnum;
@ -52,6 +58,7 @@ export interface Device {
number, number,
Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>[] Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>[]
>; >;
nodeErrors: Map<number, NodeError>;
connection?: MeshDevice; connection?: MeshDevice;
activePage: Page; activePage: Page;
activeNode: number; activeNode: number;
@ -71,6 +78,7 @@ export interface Device {
pkiBackup: boolean; pkiBackup: boolean;
nodeDetails: boolean; nodeDetails: boolean;
unsafeRoles: boolean; unsafeRoles: boolean;
refreshKeys: boolean;
}; };
unreadCounts: Map<number, number>; unreadCounts: Map<number, number>;
@ -111,6 +119,10 @@ export interface Device {
setMessageDraft: (message: string) => void; setMessageDraft: (message: string) => void;
setUnread: (id: number, count: number) => void; setUnread: (id: number, count: number) => void;
setQueueStatus: (status: QueueStatus) => void; setQueueStatus: (status: QueueStatus) => void;
setNodeError: (nodeNum: number, error: string) => void;
clearNodeError: (nodeNum: number) => void;
getNodeError: (nodeNum: number) => NodeError | undefined;
hasNodeError: (nodeNum: number) => boolean
} }
export interface DeviceState { export interface DeviceState {
@ -164,10 +176,12 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
pkiBackup: false, pkiBackup: false,
nodeDetails: false, nodeDetails: false,
unsafeRoles: false, unsafeRoles: false,
refreshKeys: false,
}, },
pendingSettingsChanges: false, pendingSettingsChanges: false,
messageDraft: "", messageDraft: "",
unreadCounts: new Map(), unreadCounts: new Map(),
nodeErrors: new Map(),
setStatus: (status: Types.DeviceStatusEnum) => { setStatus: (status: Types.DeviceStatusEnum) => {
set( set(
@ -534,7 +548,6 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
addTraceRoute: (traceroute) => { addTraceRoute: (traceroute) => {
set( set(
produce<DeviceState>((draft) => { produce<DeviceState>((draft) => {
console.log("addTraceRoute called");
const device = draft.devices.get(id); const device = draft.devices.get(id);
if (!device) { if (!device) {
return; return;
@ -571,10 +584,8 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
) => { ) => {
set( set(
produce<DeviceState>((draft) => { produce<DeviceState>((draft) => {
console.log("setMessageState called");
const device = draft.devices.get(id); const device = draft.devices.get(id);
if (!device) { if (!device) {
console.log("no device found for id");
return; return;
} }
const messageGroup = device.messages[type]; const messageGroup = device.messages[type];
@ -585,7 +596,6 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
const messages = messageGroup.get(messageIndex); const messages = messageGroup.get(messageIndex);
if (!messages) { if (!messages) {
console.log("no messages found for id");
return; return;
} }
@ -680,7 +690,42 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
} }
}), }),
); );
} },
setNodeError: (nodeNum, error) => {
set(
produce<DeviceState>((draft) => {
const device = draft.devices.get(id);
if (device) {
device.nodeErrors.set(nodeNum, { node: nodeNum, error });
}
}),
);
},
clearNodeError: (nodeNum: number) => {
set(
produce<DeviceState>((draft) => {
const device = draft.devices.get(id);
if (device) {
device.nodeErrors.delete(nodeNum);
}
}),
);
},
getNodeError: (nodeNum: number) => {
const device = get().devices.get(id);
if (!device) {
throw new Error("Device not found");
}
return device.nodeErrors.get(nodeNum);
},
hasNodeError: (nodeNum: number) => {
const device = get().devices.get(id);
if (!device) {
throw new Error("Device not found");
}
return device.nodeErrors.has(nodeNum);
},
}); });
}), }),
); );

34
src/core/subscriptions.ts

@ -23,15 +23,15 @@ export const subscribeAll = (
) { ) {
return; return;
} }
console.log(`Routing Error: ${routingPacket.data.variant.value}`); console.info(`Routing Error: ${routingPacket.data.variant.value}`);
break; break;
} }
case "routeReply": { case "routeReply": {
console.log(`Route Reply: ${routingPacket.data.variant.value}`); console.info(`Route Reply: ${routingPacket.data.variant.value}`);
break; break;
} }
case "routeRequest": { case "routeRequest": {
console.log(`Route Request: ${routingPacket.data.variant.value}`); console.info(`Route Request: ${routingPacket.data.variant.value}`);
break; break;
} }
} }
@ -71,8 +71,6 @@ export const subscribeAll = (
}); });
connection.events.onChannelPacket.subscribe((channel) => { connection.events.onChannelPacket.subscribe((channel) => {
console.log('channel', channel);
device.addChannel(channel); device.addChannel(channel);
}); });
connection.events.onConfigPacket.subscribe((config) => { connection.events.onConfigPacket.subscribe((config) => {
@ -82,10 +80,8 @@ export const subscribeAll = (
device.setModuleConfig(moduleConfig); device.setModuleConfig(moduleConfig);
}); });
connection.events.onMessagePacket.subscribe((messagePacket) => {
console.log('messagePacket', messagePacket);
connection.events.onMessagePacket.subscribe((messagePacket) => {
device.addMessage({ device.addMessage({
...messagePacket, ...messagePacket,
state: messagePacket.from !== myNodeNum ? "ack" : "waiting", state: messagePacket.from !== myNodeNum ? "ack" : "waiting",
@ -120,8 +116,26 @@ export const subscribeAll = (
connection.events.onQueueStatus.subscribe((queueStatus) => { connection.events.onQueueStatus.subscribe((queueStatus) => {
device.setQueueStatus(queueStatus); device.setQueueStatus(queueStatus);
if (queueStatus.free < 10) { });
// start queueing messages
connection.events.onRoutingPacket.subscribe((routingPacket) => {
if (routingPacket.data.variant.case === "errorReason") {
switch (routingPacket.data.variant.value) {
case Protobuf.Mesh.Routing_Error.NO_CHANNEL:
console.error(`Routing Error: ${routingPacket.data.variant.value}`);
device.setNodeError(routingPacket.from, Protobuf.Mesh.Routing_Error[routingPacket?.data?.variant?.value]);
device.setDialogOpen("refreshKeys", true);
break;
case Protobuf.Mesh.Routing_Error.PKI_UNKNOWN_PUBKEY:
console.error(`Routing Error: ${routingPacket.data.variant.value}`);
device.setNodeError(routingPacket.from, Protobuf.Mesh.Routing_Error[routingPacket?.data?.variant?.value]);
device.setDialogOpen("refreshKeys", true);
break;
default: {
break;
}
}
} }
}); });
}; };

8
src/index.tsx

@ -1,4 +1,3 @@
import { scan } from "react-scan";
import "@app/index.css"; import "@app/index.css";
import { enableMapSet } from "immer"; import { enableMapSet } from "immer";
import "maplibre-gl/dist/maplibre-gl.css"; import "maplibre-gl/dist/maplibre-gl.css";
@ -7,13 +6,6 @@ import { createRoot } from "react-dom/client";
import { App } from "@app/App.tsx"; import { App } from "@app/App.tsx";
// run react scan tool in development mode only
// react scan must be the first import and the first line in this file in order to work properly
import.meta.env.VITE_DEBUG_SCAN &&
scan({
enabled: true,
});
const container = document.getElementById("root") as HTMLElement; const container = document.getElementById("root") as HTMLElement;
const root = createRoot(container); const root = createRoot(container);

6
src/pages/Messages.tsx

@ -13,9 +13,10 @@ import { getChannelName } from "@pages/Channels.tsx";
import { HashIcon, LockIcon, LockOpenIcon } from "lucide-react"; import { HashIcon, LockIcon, LockOpenIcon } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { MessageInput } from "@components/PageComponents/Messages/MessageInput.tsx"; import { MessageInput } from "@components/PageComponents/Messages/MessageInput.tsx";
import { cn } from "@core/utils/cn.ts";
export const MessagesPage = () => { export const MessagesPage = () => {
const { channels, nodes, hardware, messages, unreadCounts, setUnread } = useDevice(); const { channels, nodes, hardware, messages, hasNodeError, unreadCounts, setUnread } = useDevice();
const { activeChat, chatType, setActiveChat, setChatType } = useAppStore(); const { activeChat, chatType, setActiveChat, setChatType } = useAppStore();
const [searchTerm, setSearchTerm] = useState<string>(""); const [searchTerm, setSearchTerm] = useState<string>("");
const filteredNodes = Array.from(nodes.values()).filter((node) => { const filteredNodes = Array.from(nodes.values()).filter((node) => {
@ -91,6 +92,8 @@ export const MessagesPage = () => {
element={ element={
<Avatar <Avatar
text={node.user?.shortName ?? node.num.toString()} text={node.user?.shortName ?? node.num.toString()}
className={cn(hasNodeError(node.num) && "text-red-500")}
showError={hasNodeError(node.num)}
size="sm" size="sm"
/> />
} }
@ -154,7 +157,6 @@ export const MessagesPage = () => {
)} )}
</div> </div>
{/* Single message input for both chat types */}
<div className="shrink-0 p-4 w-full dark:bg-slate-900"> <div className="shrink-0 p-4 w-full dark:bg-slate-900">
<MessageInput <MessageInput
to={messageDestination} to={messageDestination}

109
src/pages/Nodes.tsx

@ -19,10 +19,22 @@ export interface DeleteNoteDialogProps {
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
} }
function shortNameFromNode(
node: ReturnType<useDevice>["nodes"][number],
): string {
const shortNameOfNode = node.user?.shortName ??
(node.user?.macaddr
? `${
base16
.stringify(node.user?.macaddr.subarray(4, 6) ?? [])
.toLowerCase()
}`
: `${numberToHexUnpadded(node.num).slice(-4)}`);
return String(shortNameOfNode);
}
const NodesPage = (): JSX.Element => { const NodesPage = (): JSX.Element => {
const { nodes, hardware, connection } = useDevice(); const { nodes, hardware, connection } = useDevice();
console.log(connection);
const [selectedNode, setSelectedNode] = useState< const [selectedNode, setSelectedNode] = useState<
Protobuf.Mesh.NodeInfo | undefined Protobuf.Mesh.NodeInfo | undefined
>(undefined); >(undefined);
@ -63,7 +75,6 @@ const NodesPage = (): JSX.Element => {
}; };
}, [connection]); }, [connection]);
const handleLocation = useCallback( const handleLocation = useCallback(
(location: Types.PacketMetadata<Protobuf.Mesh.Position>) => { (location: Types.PacketMetadata<Protobuf.Mesh.Position>) => {
if (location.to.valueOf() !== hardware.myNodeNum) return; if (location.to.valueOf() !== hardware.myNodeNum) return;
@ -89,82 +100,70 @@ const NodesPage = (): JSX.Element => {
<Table <Table
headings={[ headings={[
{ title: "", type: "blank", sortable: false }, { title: "", type: "blank", sortable: false },
{ title: "Short Name", type: "normal", sortable: true },
{ title: "Long Name", type: "normal", sortable: true }, { title: "Long Name", type: "normal", sortable: true },
{ title: "Model", type: "normal", sortable: true }, { title: "Connection", type: "normal", sortable: true },
{ title: "MAC Address", type: "normal", sortable: true },
{ title: "Last Heard", type: "normal", sortable: true }, { title: "Last Heard", type: "normal", sortable: true },
{ title: "SNR", type: "normal", sortable: true },
{ title: "Encryption", type: "normal", sortable: false }, { title: "Encryption", type: "normal", sortable: false },
{ title: "Connection", type: "normal", sortable: true }, { title: "SNR", type: "normal", sortable: true },
{ title: "Model", type: "normal", sortable: true },
{ title: "MAC Address", type: "normal", sortable: true },
]} ]}
rows={filteredNodes.map((node) => [ rows={filteredNodes.map((node) => [
<div key={node.num}> <div key={node.num}>
<Avatar text={node.user?.shortName.toString() ?? "UNK"} /> <Avatar text={shortNameFromNode(node)} />
</div>, </div>,
<h1
key="shortName"
onMouseDown={() => setSelectedNode(node)}
className="cursor-pointer"
>
{node.user?.shortName ??
(node.user?.macaddr
? `${base16
.stringify(node.user?.macaddr.subarray(4, 6) ?? [])
.toLowerCase()}`
: `${numberToHexUnpadded(node.num).slice(-4)}`)}
</h1>,
<h1 <h1
key="longName" key="longName"
onMouseDown={() => setSelectedNode(node)} onMouseDown={() => setSelectedNode(node)}
className="cursor-pointer" onKeyUp={(evt) => {
evt.key === "Enter" && setSelectedNode(node);
}}
className="cursor-pointer underline"
tabIndex={0}
role="button"
> >
{node.user?.longName ?? {node.user?.longName ??
(node.user?.macaddr (node.user?.macaddr
? `Meshtastic ${base16 ? `Meshtastic ${
.stringify(node.user?.macaddr.subarray(4, 6) ?? []) base16
.toLowerCase()}` .stringify(node.user?.macaddr.subarray(4, 6) ?? [])
.toLowerCase()
}`
: `!${numberToHexUnpadded(node.num)}`)} : `!${numberToHexUnpadded(node.num)}`)}
</h1>, </h1>,
<Mono key="hops">
<Mono key="model"> {node.lastHeard !== 0
{Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]} ? node.viaMqtt === false && node.hopsAway === 0
? "Direct"
: `${node.hopsAway?.toString()} ${
node.hopsAway > 1 ? "hops" : "hop"
} away`
: "-"}
{node.viaMqtt === true ? ", via MQTT" : ""}
</Mono>, </Mono>,
<Mono key="addr"> <Mono key="lastHeard">
{base16 {node.lastHeard === 0
.stringify(node.user?.macaddr ?? []) ? <p>Never</p>
.match(/.{1,2}/g) : <TimeAgo timestamp={node.lastHeard * 1000} />}
?.join(":") ?? "UNK"}
</Mono>, </Mono>,
<Mono className="px-4" key="lastHeard"> <Mono key="pki">
{node.lastHeard === 0 ? ( {node.user?.publicKey && node.user?.publicKey.length > 0
<p className="px-4">Never</p> ? <LockIcon className="text-green-600 mx-auto" />
) : ( : <LockOpenIcon className="text-yellow-300 mx-auto" />}
<TimeAgo timestamp={node.lastHeard * 1000} />
)}
</Mono>, </Mono>,
<Mono key="snr"> <Mono key="snr">
{node.snr}db/ {node.snr}db/
{Math.min(Math.max((node.snr + 10) * 5, 0), 100)}%/ {Math.min(Math.max((node.snr + 10) * 5, 0), 100)}%/
{(node.snr + 10) * 5}raw {(node.snr + 10) * 5}raw
</Mono>, </Mono>,
<Mono key="pki"> <Mono key="model">
{node.user?.publicKey && node.user?.publicKey.length > 0 ? ( {Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]}
<LockIcon className="text-green-600 mx-auto" />
) : (
<LockOpenIcon className="text-yellow-300 mx-auto" />
)}
</Mono>, </Mono>,
<Mono key="hops"> <Mono key="addr">
{node.lastHeard !== 0 {base16
? node.viaMqtt === false && node.hopsAway === 0 .stringify(node.user?.macaddr ?? [])
? "Direct" .match(/.{1,2}/g)
: `${node.hopsAway?.toString()} ${node.hopsAway > 1 ? "hops" : "hop" ?.join(":") ?? "UNK"}
} away`
: "-"}
{node.viaMqtt === true ? ", via MQTT" : ""}
</Mono>, </Mono>,
])} ])}
/> />

9
vite.config.ts

@ -44,11 +44,8 @@ export default defineConfig({
server: { server: {
port: 3000, port: 3000,
headers: { headers: {
'Cross-Origin-Opener-Policy': 'same-origin', "Cross-Origin-Opener-Policy": "same-origin",
'Cross-Origin-Embedder-Policy': 'require-corp', "Cross-Origin-Embedder-Policy": "require-corp",
} },
},
optimizeDeps: {
exclude: ['react-scan']
}, },
}); });
Loading…
Cancel
Save