96 Commits

Author SHA1 Message Date
Dan Ditomaso
633b99d6b2 Merge pull request #554 from danditomaso/fix-pkg-import-issue
fix: moved meshtastic packages into package.json
2025-04-01 20:48:17 -04:00
Dan Ditomaso
87159b4eee fix: moved meshtastic packages into package.json 2025-04-01 20:45:36 -04:00
Dan Ditomaso
6d39ecc7b9 Merge pull request #553 from danditomaso/update-deps-to-latest
updated deps to latest
2025-04-01 16:51:29 -04:00
Dan Ditomaso
7738661b7c updated deps 2025-04-01 16:50:39 -04:00
Dan Ditomaso
4689ebe3ce Merge pull request #551 from Hunter275/position-precision-v2
Fix Position Precision for non-Primary channels
2025-04-01 11:19:33 -04:00
Hunter275
6cd8ce5102 fix for non-Primary channels 2025-03-31 23:14:20 -04:00
Dan Ditomaso
fed65d9c8b Merge pull request #547 from danditomaso/issue-523-add-udp-toggle
feat: Add udp over mesh toggle
2025-03-30 20:13:03 -04:00
Dan Ditomaso
8f225f4d28 Merge pull request #544 from James9074/loading-device
Cleaner Device Loading UX
2025-03-30 17:26:10 -04:00
Dan Ditomaso
11e820d1d0 Merge pull request #543 from danditomaso/issue-542-add-reboot-to-command-menu
added reboot to command menu
2025-03-30 17:25:30 -04:00
Dan Ditomaso
95fc72173f adding tests 2025-03-29 22:59:46 -04:00
Dan Ditomaso
03b5c639fb feat: add udp over mesh toggle 2025-03-29 22:19:53 -04:00
Dan Ditomaso
4d30558aca Merge pull request #546 from danditomaso/issue-545-nightly-docker-not-starting
Fixed Docker Builds Not Serving Site on 8080
2025-03-29 17:02:06 -04:00
Dan Ditomaso
7f376186b4 Merge pull request #540 from James9074/dm-from-map
Allow users to DM nodes directly from the map
2025-03-29 16:00:56 -04:00
Dan Ditomaso
0de24c41ed fix: docker build process 2025-03-29 15:54:15 -04:00
James Thomas
88c4f84edb Extended loading 2025-03-28 22:49:21 -04:00
Dan Ditomaso
bf9557040f fixed: import issue 2025-03-28 21:12:13 -04:00
Dan Ditomaso
6d9a44a0e3 added tests 2025-03-28 21:11:01 -04:00
Dan Ditomaso
35aabdc900 feat: added reboot to OTA in command menu 2025-03-28 21:06:37 -04:00
Dan Ditomaso
163502156d Merge pull request #541 from danditomaso/add-tzdef
feat: added tzdef to device config
2025-03-28 14:27:12 -04:00
Dan Ditomaso
8baa5d84b9 added reboot to command menu 2025-03-28 12:12:56 -04:00
Dan Ditomaso
c55fdbd982 wip 2025-03-28 11:45:48 -04:00
James Thomas
8da38ab2e4 Prevent tooltip from appearing by default 2025-03-28 09:57:35 -04:00
Dan Ditomaso
dddb781627 feat: added tzdef to device config 2025-03-27 20:50:27 -04:00
James Thomas
77b3a7ac85 Lint 2025-03-27 17:35:18 -04:00
James Thomas
626970865f Lint 2025-03-27 17:34:42 -04:00
James Thomas
c0308532a1 Adding DM from Map function 2025-03-27 17:21:40 -04:00
Dan Ditomaso
a378cce0be Merge pull request #538 from danditomaso/issue-537-position-flags-undefined
fix: ensured undefined position flags are handled
2025-03-26 13:08:55 -04:00
Dan Ditomaso
488fd61558 fix: ensured undefined position flags are handled 2025-03-25 15:49:44 -04:00
Dan Ditomaso
dcbfb08f26 Merge pull request #528 from danditomaso/add-dismiss-to-key-reminder
Refactor useBackupReminder hook
2025-03-23 22:20:57 -04:00
Dan Ditomaso
a7a448cbcd refactor: improved how reminder expiry dates are handled. 2025-03-21 23:34:20 -04:00
Dan Ditomaso
1780c6fb2a refactor: updated how expiry dates are handled. 2025-03-21 22:39:40 -04:00
Hunter Thornsberry
2d54df7dba Merge pull request #532 from Hunter275/node-count-off-by-one
Subtract one from node count
2025-03-21 14:39:24 -04:00
Hunter Thornsberry
890674eea3 subtract one from node count 2025-03-21 14:26:19 -04:00
Dan Ditomaso
93a70dfd47 Merge pull request #529 from danditomaso/feat/update-readme
Updated repo readme
2025-03-21 07:53:24 -04:00
Dan Ditomaso
6ac8646323 Merge pull request #500 from Hunter275/browser-feature-rework
Update style and wording of browser support for connection types
2025-03-20 21:40:12 -04:00
Dan Ditomaso
a215da1ebe Merge pull request #526 from bkimmel/bkimmel/reorder-nodes-columns
reorder columns in Nodes page
2025-03-20 16:54:51 -04:00
Dan Ditomaso
22dbfbcc09 feat: added never remind me to key reminder. 2025-03-20 14:40:15 -04:00
Dan Ditomaso
6341d564d3 feat: update readme with domain changes 2025-03-20 12:06:21 -04:00
bkimmel
28cc7b9800 reorder columns in Nodes page 2025-03-19 17:50:20 -04:00
Dan Ditomaso
5a142e671d Merge pull request #525 from danditomaso/fix/remove-react-scan
Revert: Remove react scan
2025-03-19 14:55:33 -04:00
Dan Ditomaso
ba3d45584d adding lock file 2025-03-19 13:52:25 -04:00
Dan Ditomaso
f54c0dd836 fix: removed react-scan due to issues with bluetooth 2025-03-19 13:46:56 -04:00
Dan Ditomaso
a6427a9ed1 Merge pull request #520 from bkimmel/bkimmel/labels-on-icons
add a label to theme icon
2025-03-19 13:26:40 -04:00
Dan Ditomaso
11058dbf3b Merge pull request #516 from danditomaso/feat/add-key-mismatch-error-handling
feat: add error handling for key mismatch
2025-03-19 12:42:38 -04:00
Dan Ditomaso
d062c2f1ab Merge pull request #522 from danditomaso/fix-node-details-styling
fix: styling issues in NodeDialog & TraceRoute components
2025-03-19 08:11:08 -04:00
bkimmel
1f109d161f deno format 2025-03-18 23:28:44 -04:00
bkimmel
f2d6daa9fc safety coalesce 2025-03-18 22:57:22 -04:00
bkimmel
9634e1ce39 PR Feedback: h/t Dan & Hunter 2025-03-18 22:52:58 -04:00
Dan Ditomaso
64055a5aeb fixed spacing and updated wording on dialog 2025-03-18 22:09:20 -04:00
Dan Ditomaso
ad366e6bab added additional routing packet error handler 2025-03-18 19:44:09 -04:00
Dan Ditomaso
9399104914 fix: resolved issues with styling 2025-03-18 15:21:58 -04:00
bkimmel
f82bc660b0 add a label to theme icon 2025-03-18 00:06:38 -04:00
Dan Ditomaso
ed13af2382 Merge pull request #514 from bkimmel/bkimmel/nodespage-fixes-1
small-scale Nodes page fixes
2025-03-17 22:49:53 -04:00
bkimmel
e4c2952e49 dark mode adjustments 2025-03-17 18:15:43 -04:00
Dan Ditomaso
0830eb9971 Merge pull request #518 from danditomaso/issue-515-add-node-count
feat: added node count to sidebar
2025-03-17 17:10:06 -04:00
Dan Ditomaso
be9b61ec0c feat: added node count to sidebar 2025-03-17 13:20:21 -04:00
Hunter Thornsberry
be0fe08f2f Merge pull request #513 from Hunter275/position-precision-fix
Position Precision Rework
2025-03-16 23:06:55 -04:00
Dan Ditomaso
3f8d3389d5 feat: add error handling for key mismatch 2025-03-16 22:56:58 -04:00
Hunter Thornsberry
20af1b4d34 change submit to be outlined 2025-03-16 19:35:26 -04:00
bkimmel
207061e9d8 small-scale Nodes page fixes 2025-03-16 09:41:50 -04:00
Hunter Thornsberry
6633fc9c55 Merge branch 'master' into position-precision-fix 2025-03-16 01:34:57 -04:00
Hunter275
52b80613f8 position precision rework 2025-03-16 01:33:05 -04:00
Hunter275
db2cb8cb42 update style and wording of browser support for connection types 2025-03-13 01:29:03 -04:00
Dan Ditomaso
c320d7d173 Merge pull request #499 from Hunter275/position_precision_stop_gap
Stop Gap: Remove Position Precision
2025-03-12 23:42:44 -04:00
Hunter Thornsberry
db50bb5c1b stop gap for channel position precision until fix is worked out 2025-03-12 22:32:01 -04:00
Dan Ditomaso
3240ac57f7 Merge pull request #492 from bkimmel/issue459/direct_nodes
Fix: issue 459 / sort direct nodes
2025-03-10 21:57:08 -04:00
Dan Ditomaso
2008b09ca3 Merge pull request #494 from danditomaso/issue-486-are-you-sure-dialog
Issue 486 are you sure dialog
2025-03-10 21:06:30 -04:00
Dan Ditomaso
491f72b426 fix for broken test 2025-03-10 21:00:01 -04:00
Dan Ditomaso
a6f46bd38a commit lock 2025-03-10 20:56:32 -04:00
Dan Ditomaso
2cebb8eee2 refactor: added close button back to dialog 2025-03-10 20:53:09 -04:00
Dan Ditomaso
33ad9f989c fix: fixed vitest file after merge conflict 2025-03-10 20:46:46 -04:00
bkimmel
c590ab2ff5 Fix: issue 459 / sort direct nodes 2025-03-10 20:31:52 -04:00
Dan Ditomaso
9da949d27a Merge branch 'master' into issue-486-are-you-sure-dialog 2025-03-10 20:31:33 -04:00
Dan Ditomaso
f1a58f0434 refactor: fixed unsafe roles dialog and hook logic, added tests 2025-03-10 20:30:04 -04:00
Dan Ditomaso
0296b241e4 Merge pull request #487 from danditomaso/issue-455-cant-scroll-up-in-chat
fix: resolved issue with being unable to scroll up in the input field
2025-03-10 15:11:15 -04:00
Dan Ditomaso
344ad48858 fix: improved style of the message input field 2025-03-10 09:26:01 -04:00
Dan Ditomaso
97f2abb582 fix: added tests to branch 2025-03-09 12:57:51 -04:00
Dan Ditomaso
eca5d780c1 feat: added are you sure dialog 2025-03-09 12:57:37 -04:00
Dan Ditomaso
844a6316f6 fix: remove unneeded role 2025-03-08 21:15:34 -05:00
Dan Ditomaso
d39c5ed079 Merge pull request #490 from danditomaso/issue-489-bluetooth-uuid-not-set
fix: restored correct BLE service uuid to BLE devices filter
2025-03-08 21:01:22 -05:00
Dan Ditomaso
09bb0bc43a fix: added ble header to vercel config 2025-03-08 16:28:20 -05:00
Dan Ditomaso
266e27bfe9 fix: added BLE uuid back to BLE connection component 2025-03-08 16:26:26 -05:00
Dan Ditomaso
5b11131e08 fix: remove unneeded role 2025-03-08 12:19:26 -05:00
Dan Ditomaso
d70b14b12b fix: failing tests 2025-03-06 16:09:22 -05:00
Dan Ditomaso
c115ac0749 fix: improved hover state for message input button 2025-03-06 14:39:09 -05:00
Dan Ditomaso
d54a612e0b fix: restore aliased paths to vite config 2025-03-06 14:28:07 -05:00
Dan Ditomaso
d379769672 fix: resolved issue with being unable to scroll up in the input field 2025-03-06 14:09:47 -05:00
Dan Ditomaso
b670ffe407 Merge pull request #484 from varanauskas/patch-1
Update HTTP.test.tsx to ensure "https://" prefix is used if needed
2025-03-05 20:28:54 -05:00
Tadas Varanauskas
4ffbe03b22 Update HTTP.test.tsx to ensure "https://" prefix is used if needed
Small test update to prevent regression of #481 

#482 fixed the issue, this new test would have failed before, and will prevent reoccurrence of the issue
2025-03-05 20:09:11 +02:00
Dan Ditomaso
6a438470cf Merge pull request #483 from danditomaso/fix/restore-window-to-http
fix: remove GlobalThis and use window instead
2025-03-05 10:53:52 -05:00
Dan Ditomaso
4d0d1da691 fix: remove GlobalThis and use window instead 2025-03-05 08:54:26 -05:00
Dan Ditomaso
39f26f475b Merge pull request #482 from danditomaso/issue-481-node-connecting-on-https
fix: update TLS setting if URL is using HTTPS

Fixes #481
2025-03-05 08:30:18 -05:00
Dan Ditomaso
35fed173af fix: update TLS setting if URL is using HTTPS 2025-03-05 08:14:59 -05:00
Dan Ditomaso
a8b0515949 Merge pull request #480 from danditomaso/issue-479-node-connecting-on-https
fix: restored https toggle functionality. added tests
2025-03-04 14:38:29 -05:00
Dan Ditomaso
bd9d599934 fix: if url is already https, toggle should be checked 2025-03-04 14:10:46 -05:00
Dan Ditomaso
b40079cdc9 fix: restored https toggle functionality. added tests 2025-03-04 13:43:02 -05:00
109 changed files with 4894 additions and 4144 deletions

View File

@@ -1,2 +0,0 @@
dist/build.tar
dist/output

View File

@@ -46,7 +46,7 @@ jobs:
uses: redhat-actions/buildah-build@v2
with:
containerfiles: |
./Containerfile
./infra/Containerfile
image: ${{github.event.repository.full_name}}
tags: nightly ${{ github.sha }}
oci: true

View File

@@ -46,7 +46,7 @@ jobs:
uses: redhat-actions/buildah-build@v2
with:
containerfiles: |
./Containerfile
./infra/Containerfile
image: ${{github.event.repository.full_name}}
tags: latest ${{ github.sha }}
oci: true

3
.gitignore vendored
View File

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

View File

@@ -1,10 +0,0 @@
FROM nginx:1.27.2-alpine
RUN rm -r /usr/share/nginx/html \
&& mkdir /usr/share/nginx/html
WORKDIR /usr/share/nginx/html
ADD dist .
CMD nginx -g "daemon off;"

View File

@@ -139,46 +139,28 @@ reasons:
- **Web Standard APIs**: Uses browser-compatible APIs, making code more portable
between server and client environments.
### Debugging
### Contributing
#### Debugging with React Scan
We welcome contributions! Heres 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
resolve render performance issues during development.
- **Preview Deployments:**\
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:
[client-test.meshtastic.org](https://client-test.meshtastic.org/).\
This environment supports rapid feature iteration and testing without
impacting the production site.
- Inline function callbacks (`onClick={() => handleClick()}`)
- Object literals (`style={{ color: "purple" }}`)
- Array literals (`items={[1, 2, 3]}`)
- **Production Releases:**\
At regular intervals, stable and fully tested releases are promoted to our
production site: [client.meshtastic.org](https://client.meshtastic.org/).\
This is the primary interface used by the public to connect with their
Meshtastic nodes.
These are recreated on every render, causing child components to re-render even
when nothing has actually changed.
Unlike React DevTools, React Scan specifically focuses on performance
optimization by:
- Clearly distinguishing between necessary and unnecessary renders
- Providing render counts for components
- Highlighting slow-rendering components
- Offering a dedicated performance debugging experience
#### 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.
Please review our
[Contribution Guidelines](https://github.com/meshtastic/web/blob/master/CONTRIBUTING.md)
before submitting a pull request. We appreciate your help in making the project
better!

2058
bun.lock
View File

File diff suppressed because it is too large Load Diff

1790
deno.lock generated
View File

File diff suppressed because it is too large Load Diff

2
infra/.dockerignore Normal file
View File

@@ -0,0 +1,2 @@
../dist/build.tar
../dist/output

15
infra/Containerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM nginx:1.27-alpine
RUN rm -r /usr/share/nginx/html \
&& mkdir -p /usr/share/nginx/html \
&& mkdir -p /etc/nginx/conf.d
WORKDIR /usr/share/nginx/html
ADD dist .
COPY ./infra/default.conf /etc/nginx/conf.d/default.conf
EXPOSE 8080
CMD ["nginx", "-g", "daemon off;"]

42
infra/default.conf Normal file
View File

@@ -0,0 +1,42 @@
server {
listen 8080;
server_name localhost;
root /usr/share/nginx/html;
index index.html index.htm;
location / {
try_files $uri $uri/ =404;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
internal;
}
location ~ /\.ht {
deny all;
}
gzip on;
gzip_disable "msie6";
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_http_version 1.1;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/javascript
application/x-javascript
application/json
application/xml
application/xml+rss
font/ttf
font/otf
image/svg+xml;
}

View File

@@ -1,6 +1,6 @@
{
"name": "meshtastic-web",
"version": "2.3.3-0",
"version": "2.6.0-0",
"type": "module",
"description": "Meshtastic web client",
"license": "GPL-3.0-only",
@@ -12,9 +12,7 @@
"format": "deno fmt src/",
"dev": "deno task dev:ui",
"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:ui": "deno task test --ui",
"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/ ."
},
@@ -36,11 +34,12 @@
},
"homepage": "https://meshtastic.org",
"dependencies": {
"@bufbuild/protobuf": "^2.2.3",
"@meshtastic/core": "npm:@jsr/meshtastic__core@2.6.0-0",
"@meshtastic/core": "npm:@jsr/meshtastic__core@2.6.2",
"@meshtastic/js": "npm:@jsr/meshtastic__js@2.6.0-0",
"@meshtastic/transport-http": "npm:@jsr/meshtastic__transport-http",
"@meshtastic/transport-web-bluetooth": "npm:@jsr/meshtastic__transport-web-bluetooth",
"@meshtastic/transport-web-serial": "npm:@jsr/meshtastic__transport-web-serial",
"@bufbuild/protobuf": "^2.2.5",
"@noble/curves": "^1.8.1",
"@radix-ui/react-accordion": "^1.2.3",
"@radix-ui/react-checkbox": "^1.1.4",
@@ -61,47 +60,50 @@
"class-validator": "^0.14.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.4",
"cmdk": "^1.1.1",
"crypto-random-string": "^5.0.0",
"immer": "^10.1.1",
"js-cookie": "^3.0.5",
"lucide-react": "^0.477.0",
"maplibre-gl": "5.1.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"lucide-react": "^0.486.0",
"maplibre-gl": "5.3.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-error-boundary": "^5.0.0",
"react-hook-form": "^7.54.2",
"react-map-gl": "8.0.1",
"react-hook-form": "^7.55.0",
"react-map-gl": "8.0.2",
"react-qrcode-logo": "^3.0.0",
"react-scan": "^0.2.8",
"rfc4648": "^1.5.4",
"vite-plugin-node-polyfills": "^0.23.0",
"zod": "^3.24.2",
"zustand": "5.0.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.0.9",
"@tailwindcss/postcss": "^4.1.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@types/chrome": "^0.0.307",
"@testing-library/user-event": "^14.6.1",
"@types/chrome": "^0.0.313",
"@types/js-cookie": "^3.0.6",
"@types/node": "^22.13.7",
"@types/react": "^19.0.10",
"@types/node": "^22.13.17",
"@types/react": "^19.0.12",
"@types/react-dom": "^19.0.4",
"@types/serviceworker": "^0.0.123",
"@types/serviceworker": "^0.0.127",
"@types/w3c-web-serial": "^1.0.8",
"@types/web-bluetooth": "^0.0.21",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"gzipper": "^8.2.0",
"happy-dom": "^17.1.8",
"autoprefixer": "^10.4.21",
"gzipper": "^8.2.1",
"happy-dom": "^17.4.4",
"postcss": "^8.5.3",
"simple-git-hooks": "^2.11.1",
"tailwind-merge": "^3.0.2",
"tailwindcss": "^4.0.9",
"simple-git-hooks": "^2.12.1",
"tailwind-merge": "^3.1.0",
"tailwindcss": "^4.1.0",
"tailwindcss-animate": "^1.0.7",
"tar": "^7.4.3",
"testing-library": "^0.0.2",
"typescript": "^5.8.2",
"vite": "^6.2.0",
"vite-plugin-pwa": "^0.21.1",
"vitest": "^3.0.7"
"vite": "^6.2.4",
"vitest": "^3.1.1",
"vite-plugin-pwa": "^1.0.0"
}
}

View File

@@ -1,6 +1,5 @@
import { DeviceWrapper } from "@app/DeviceWrapper.tsx";
import { PageRouter } from "@app/PageRouter.tsx";
import { CommandPalette } from "@components/CommandPalette.tsx";
import { DeviceSelector } from "@components/DeviceSelector.tsx";
import { DialogManager } from "@components/Dialog/DialogManager.tsx";
import { NewDeviceDialog } from "@components/Dialog/NewDeviceDialog.tsx";
@@ -14,6 +13,7 @@ import type { JSX } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { ErrorPage } from "@components/UI/ErrorPage.tsx";
import { MapProvider } from "react-map-gl/maplibre";
import { CommandPalette } from "@components/CommandPalette/index.tsx";
export const App = (): JSX.Element => {

43
src/__mocks__/README.md Normal file
View File

@@ -0,0 +1,43 @@
# Mocks Directory
This directory contains mock implementations used by Vitest for testing.
## Structure
The directory structure mirrors the actual project structure to make mocking
more intuitive:
```
__mocks__/
├── components/
│ └── UI/
│ ├── Dialog.tsx
│ ├── Button.tsx
│ ├── Checkbox.tsx
│ └── ...
├── core/
│ └── ...
└── ...
```
## Auto-mocking
Vitest will automatically use the mock files in this directory when the
corresponding module is imported in tests. For example, when a test imports
`@components/UI/Dialog.tsx`, Vitest will use
`__mocks__/components/UI/Dialog.tsx` instead.
## Creating New Mocks
To create a new mock:
1. Create a file in the same relative path as the original module
2. Export the mocked functionality with the same names as the original
3. Add a `vi.mock()` statement to `vitest.setup.ts` if needed
## Mock Guidelines
- Keep mocks as simple as possible
- Use `data-testid` attributes for easy querying in tests
- Implement just enough functionality to test the component
- Use TypeScript types to ensure compatibility with the original module

View File

@@ -0,0 +1,20 @@
import { vi } from 'vitest'
vi.mock('@components/UI/Button.tsx', () => ({
Button: ({ children, name, disabled, onClick }: {
children: React.ReactNode,
variant: string,
name: string,
disabled?: boolean,
onClick: () => void
}) =>
<button
type="button"
name={name}
data-testid={`button-${name}`}
disabled={disabled}
onClick={onClick}
>
{children}
</button>
}));

View File

@@ -0,0 +1,6 @@
import { vi } from 'vitest'
vi.mock('@components/UI/Checkbox.tsx', () => ({
Checkbox: ({ id, checked, onChange }: { id: string, checked: boolean, onChange: () => void }) =>
<input data-testid="checkbox" type="checkbox" id={id} checked={checked} onChange={onChange} />
}));

View File

@@ -0,0 +1,43 @@
import React from 'react';
export const Dialog = ({ children, open }: {
children: React.ReactNode,
open: boolean,
onOpenChange?: (open: boolean) => void
}) => open ? <div data-testid="dialog">{children}</div> : null;
export const DialogContent = ({
children,
className
}: {
children: React.ReactNode,
className?: string
}) => <div data-testid="dialog-content" className={className}>{children}</div>;
export const DialogHeader = ({
children
}: {
children: React.ReactNode
}) => <div data-testid="dialog-header">{children}</div>;
export const DialogTitle = ({
children
}: {
children: React.ReactNode
}) => <div data-testid="dialog-title">{children}</div>;
export const DialogDescription = ({
children,
className
}: {
children: React.ReactNode,
className?: string
}) => <div data-testid="dialog-description" className={className}>{children}</div>;
export const DialogFooter = ({
children,
className
}: {
children: React.ReactNode,
className?: string
}) => <div data-testid="dialog-footer" className={className}>{children}</div>;

View File

@@ -0,0 +1,6 @@
import { vi } from 'vitest'
vi.mock('@components/UI/Label.tsx', () => ({
Label: ({ children, htmlFor, className }: { children: React.ReactNode, htmlFor: string, className?: string }) =>
<label data-testid="label" htmlFor={htmlFor} className={className}>{children}</label>
}));

View File

@@ -0,0 +1,7 @@
import { vi } from "vitest";
vi.mock('@components/UI/Typography/Link.tsx', () => ({
Link: ({ children, href, className }: { children: React.ReactNode, href: string, className?: string }) =>
<a data-testid="link" href={href} className={className}>{children}</a>
}));

View File

@@ -1,4 +1,3 @@
import { Avatar } from "./UI/Avatar.tsx";
import {
CommandDialog,
CommandEmpty,
@@ -18,7 +17,6 @@ import {
FactoryIcon,
LayersIcon,
LinkIcon,
type LucideIcon,
MapIcon,
MessageSquareIcon,
PlusIcon,
@@ -29,9 +27,13 @@ import {
SmartphoneIcon,
TrashIcon,
UsersIcon,
XCircleIcon,
Pin,
type LucideIcon,
} from "lucide-react";
import { useEffect } from "react";
import { Avatar } from "@components/UI/Avatar.tsx";
import { cn } from "@core/utils/cn.ts";
import { usePinnedItems } from "@core/hooks/usePinnedItems.ts";
export interface Group {
label: string;
@@ -45,7 +47,6 @@ export interface Command {
subItems?: SubItem[];
tags?: string[];
}
export interface SubItem {
label: string;
icon: React.ReactNode;
@@ -57,11 +58,10 @@ export const CommandPalette = () => {
commandPaletteOpen,
setCommandPaletteOpen,
setSelectedDevice,
removeDevice,
selectedDevice,
} = useAppStore();
const { getDevices } = useDeviceStore();
const { setDialogOpen, setActivePage, connection } = useDevice();
const { pinnedItems, togglePinnedItem } = usePinnedItems({ storageName: 'pinnedCommandMenuGroups' });
const groups: Group[] = [
{
@@ -113,22 +113,22 @@ export const CommandPalette = () => {
{
label: "Switch Node",
icon: ArrowLeftRightIcon,
subItems: getDevices().map((device) => {
return {
label:
device.nodes.get(device.hardware.myNodeNum)?.user?.longName ??
device.hardware.myNodeNum.toString(),
icon: (
<Avatar
text={device.nodes.get(device.hardware.myNodeNum)?.user
?.shortName ?? device.hardware.myNodeNum.toString()}
/>
),
action() {
setSelectedDevice(device.id);
},
};
}),
subItems: getDevices().map((device) => ({
label:
device.nodes.get(device.hardware.myNodeNum)?.user?.longName ??
device.hardware.myNodeNum.toString(),
icon: (
<Avatar
text={
device.nodes.get(device.hardware.myNodeNum)?.user?.shortName ??
device.hardware.myNodeNum.toString()
}
/>
),
action() {
setSelectedDevice(device.id);
},
})),
},
{
label: "Connect New Node",
@@ -163,15 +163,6 @@ export const CommandPalette = () => {
},
],
},
{
label: "Disconnect",
icon: XCircleIcon,
action() {
void connection?.disconnect();
setSelectedDevice(0);
removeDevice(selectedDevice ?? 0);
},
},
{
label: "Schedule Shutdown",
icon: PowerIcon,
@@ -186,6 +177,13 @@ export const CommandPalette = () => {
setDialogOpen("reboot", true);
},
},
{
label: "Reboot To OTA Mode",
icon: RefreshCwIcon,
action() {
setDialogOpen("rebootOTA", true);
},
},
{
label: "Reset Nodes",
icon: TrashIcon,
@@ -231,6 +229,12 @@ export const CommandPalette = () => {
},
];
const sortedGroups = [...groups].sort((a, b) => {
const aPinned = pinnedItems.includes(a.label) ? 1 : 0;
const bPinned = pinnedItems.includes(b.label) ? 1 : 0;
return bPinned - aPinned;
});
useEffect(() => {
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
@@ -244,15 +248,45 @@ export const CommandPalette = () => {
}, [setCommandPaletteOpen]);
return (
<CommandDialog
open={commandPaletteOpen}
onOpenChange={setCommandPaletteOpen}
>
<CommandDialog open={commandPaletteOpen} onOpenChange={setCommandPaletteOpen}>
<CommandInput placeholder="Type a command or search..." />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
{groups.map((group) => (
<CommandGroup key={group.label} heading={group.label}>
{sortedGroups.map((group) => (
<CommandGroup
key={group.label}
heading={
<div className="flex items-center justify-between">
<span>{group.label}</span>
<button
type="button"
onClick={() => togglePinnedItem(group.label)}
className={cn(
"transition-all duration-300 scale-100 cursor-pointer m-0.5 p-2 focus:*:data-label:opacity-100"
)}
aria-description={
pinnedItems.includes(group.label)
? "Unpin command group"
: "Pin command group"
}
>
<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"
/>
<Pin
size={16}
className={cn(
"transition-opacity",
pinnedItems.includes(group.label)
? "opacity-100 text-red-500"
: "opacity-40 hover:opacity-70"
)}
/>
</button>
</div>
}
>
{group.commands.map((command) => (
<div key={command.label}>
<CommandItem

View File

@@ -3,6 +3,7 @@ import { create } from "@bufbuild/protobuf";
import { Button } from "@components/UI/Button.tsx";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
@@ -52,6 +53,7 @@ export const DeviceNameDialog = ({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>Change Device Name</DialogTitle>
<DialogDescription>

View File

@@ -1,13 +1,15 @@
import { useDevice } from "@core/stores/deviceStore.ts";
import { RemoveNodeDialog } from "@components/Dialog/RemoveNodeDialog.tsx";
import { DeviceNameDialog } from "@components/Dialog/DeviceNameDialog.tsx";
import { ImportDialog } from "@components/Dialog/ImportDialog.tsx";
import { PkiBackupDialog } from "./PKIBackupDialog.tsx";
import { PkiBackupDialog } from "@components/Dialog/PKIBackupDialog.tsx";
import { QRDialog } from "@components/Dialog/QRDialog.tsx";
import { RebootDialog } from "@components/Dialog/RebootDialog.tsx";
import { ShutdownDialog } from "@components/Dialog/ShutdownDialog.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { NodeDetailsDialog } from "./NodeDetailsDialog.tsx";
import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx";
import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx";
import { RefreshKeysDialog } from "@components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx";
import { RebootOTADialog } from "@components/Dialog/RebootOTADialog.tsx";
export const DialogManager = () => {
const { channels, config, dialog, setDialogOpen } = useDevice();
@@ -64,6 +66,24 @@ export const DialogManager = () => {
setDialogOpen("nodeDetails", open);
}}
/>
<UnsafeRolesDialog
open={dialog.unsafeRoles}
onOpenChange={(open) => {
setDialogOpen("unsafeRoles", open);
}}
/>
<RefreshKeysDialog
open={dialog.refreshKeys}
onOpenChange={(open) => {
setDialogOpen("refreshKeys", open);
}}
/>
<RebootOTADialog
open={dialog.rebootOTA}
onOpenChange={(open) => {
setDialogOpen("rebootOTA", open);
}}
/>
</>
);
};

View File

@@ -1,8 +1,9 @@
import { create, fromBinary } from "@bufbuild/protobuf";
import { Button } from "@components/UI/Button.tsx";
import { Checkbox } from "@components/UI/Checkbox.tsx";
import { Checkbox } from "../UI/Checkbox/index.tsx";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
@@ -50,7 +51,7 @@ export const ImportDialog = ({
const paddedString = encodedChannelConfig
.padEnd(
encodedChannelConfig.length +
((4 - (encodedChannelConfig.length % 4)) % 4),
((4 - (encodedChannelConfig.length % 4)) % 4),
"=",
)
.replace(/-/g, "+")
@@ -96,6 +97,7 @@ export const ImportDialog = ({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>Import Channel Set</DialogTitle>
<DialogDescription>

View File

@@ -1,6 +1,7 @@
import { useDevice } from "../../core/stores/deviceStore.ts";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
@@ -31,6 +32,7 @@ export const LocationResponseDialog = ({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>{`Location: ${longName} (${shortName})`}</DialogTitle>
</DialogHeader>
@@ -41,9 +43,8 @@ export const LocationResponseDialog = ({
Coordinates:{" "}
<a
className="text-blue-500 dark:text-blue-400"
href={`https://www.openstreetmap.org/?mlat=${
location?.data.latitudeI / 1e7
}&mlon=${location?.data.longitudeI / 1e7}&layers=N`}
href={`https://www.openstreetmap.org/?mlat=${location?.data.latitudeI / 1e7
}&mlon=${location?.data.longitudeI / 1e7}&layers=N`}
target="_blank"
rel="noreferrer"
>

View File

@@ -7,6 +7,7 @@ import { HTTP } from "@components/PageComponents/Connect/HTTP.tsx";
import { Serial } from "@components/PageComponents/Connect/Serial.tsx";
import {
Dialog,
DialogClose,
DialogContent,
DialogHeader,
DialogTitle,
@@ -52,7 +53,7 @@ const links: { [key: string]: string } = {
const listFormatter = new Intl.ListFormat("en", {
style: "long",
type: "conjunction",
type: "disjunction",
});
const ErrorMessage = ({ missingFeatures }: FeatureErrorProps) => {
@@ -78,16 +79,16 @@ const ErrorMessage = ({ missingFeatures }: FeatureErrorProps) => {
};
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">
<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">
<p className="text-sm">
<p className="text-sm text-white">
{browserFeatures.length > 0 && (
<>
This application requires{" "}
This connection type requires{" "}
{formatFeatureList(browserFeatures)}. Please use a
Chromium-based browser like Chrome or Edge.
supported browser, like Chrome or Edge.
</>
)}
{needsSecureContext && (
@@ -135,6 +136,7 @@ export const NewDeviceDialog = ({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>Connect New Device</DialogTitle>
</DialogHeader>

View File

@@ -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,
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>
<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;
};

View File

@@ -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();
});
});

View File

@@ -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>
);
};

View File

@@ -3,6 +3,7 @@ import { useAppStore } from "../../core/stores/appStore.ts";
import { useDevice } from "../../core/stores/deviceStore.ts";
import {
Dialog,
DialogClose,
DialogContent,
DialogHeader,
DialogTitle,
@@ -72,6 +73,7 @@ export const NodeOptionsDialog = ({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>{`${longName} (${shortName})`}</DialogTitle>
</DialogHeader>

View File

@@ -2,6 +2,7 @@ import { useDevice } from "../../core/stores/deviceStore.ts";
import { Button } from "../UI/Button.tsx";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
@@ -102,6 +103,7 @@ export const PkiBackupDialog = ({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>Backup Keys</DialogTitle>
<DialogDescription>

View File

@@ -1,6 +1,7 @@
import { Button } from "@components/UI/Button.tsx";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
@@ -22,6 +23,7 @@ export const PkiRegenerateDialog = ({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>Regenerate Key pair?</DialogTitle>
<DialogDescription>

View File

@@ -1,7 +1,8 @@
import { create, toBinary } from "@bufbuild/protobuf";
import { Checkbox } from "@components/UI/Checkbox.tsx";
import { Checkbox } from "../UI/Checkbox/index.tsx";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
@@ -62,6 +63,7 @@ export const QRDialog = ({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>Generate QR Code</DialogTitle>
<DialogDescription>
@@ -77,8 +79,8 @@ export const QRDialog = ({
{channel.settings?.name.length
? channel.settings.name
: channel.role === Protobuf.Channel.Channel_Role.PRIMARY
? "Primary"
: `Channel: ${channel.index}`}
? "Primary"
: `Channel: ${channel.index}`}
</Label>
<Checkbox
key={channel.index}
@@ -106,22 +108,20 @@ export const QRDialog = ({
<div className="flex justify-center">
<button
type="button"
className={`border-slate-900 border-t border-l border-b rounded-l h-10 px-7 py-2 text-sm font-medium focus:outline-hidden focus:ring-2 focus:ring-offset-2 ${
qrCodeAdd
? "focus:ring-green-800 bg-green-800 text-white"
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
}`}
className={`border-slate-900 border-t border-l border-b rounded-l h-10 px-7 py-2 text-sm font-medium focus:outline-hidden focus:ring-2 focus:ring-offset-2 ${qrCodeAdd
? "focus:ring-green-800 bg-green-800 text-white"
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
}`}
onClick={() => setQrCodeAdd(true)}
>
Add Channels
</button>
<button
type="button"
className={`border-slate-900 border-t border-r border-b rounded-r h-10 px-4 py-2 text-sm font-medium focus:outline-hidden focus:ring-2 focus:ring-offset-2 ${
!qrCodeAdd
? "focus:ring-green-800 bg-green-800 text-white"
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
}`}
className={`border-slate-900 border-t border-r border-b rounded-r h-10 px-4 py-2 text-sm font-medium focus:outline-hidden focus:ring-2 focus:ring-offset-2 ${!qrCodeAdd
? "focus:ring-green-800 bg-green-800 text-white"
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
}`}
onClick={() => setQrCodeAdd(false)}
>
Replace Channels

View File

@@ -1,6 +1,7 @@
import { Button } from "@components/UI/Button.tsx";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
@@ -27,6 +28,7 @@ export const RebootDialog = ({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>Schedule Reboot</DialogTitle>
<DialogDescription>

View File

@@ -0,0 +1,114 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { RebootOTADialog } from './RebootOTADialog.tsx';
import { ReactNode } from "react";
const rebootOtaMock = vi.fn();
let mockConnection: { rebootOta: (delay: number) => void } | undefined = {
rebootOta: rebootOtaMock,
};
vi.mock('@core/stores/deviceStore.ts', () => ({
useDevice: () => ({
connection: mockConnection,
}),
}));
vi.mock('@components/UI/Button.tsx', async () => {
const actual = await vi.importActual('@components/UI/Button.tsx');
return {
...actual,
Button: (props: any) => <button {...props} />,
};
});
vi.mock('@components/UI/Input.tsx', async () => {
const actual = await vi.importActual('@components/UI/Input.tsx');
return {
...actual,
Input: (props: any) => <input {...props} />,
};
});
vi.mock('@components/UI/Dialog.tsx', () => {
return {
Dialog: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogTitle: ({ children }: { children: ReactNode }) => <h1>{children}</h1>,
DialogDescription: ({ children }: { children: ReactNode }) => <p>{children}</p>,
DialogClose: () => null,
};
});
describe('RebootOTADialog', () => {
beforeEach(() => {
vi.useFakeTimers();
rebootOtaMock.mockClear();
});
afterEach(() => {
vi.useRealTimers();
});
it('renders dialog with default input value', () => {
render(<RebootOTADialog open={true} onOpenChange={() => { }} />);
expect(screen.getByPlaceholderText(/enter delay/i)).toHaveValue(5);
expect(screen.getByText(/schedule reboot/i)).toBeInTheDocument();
expect(screen.getByText(/reboot to ota mode now/i)).toBeInTheDocument();
});
it('schedules a reboot with delay and calls rebootOta', async () => {
const onOpenChangeMock = vi.fn();
render(<RebootOTADialog open={true} onOpenChange={onOpenChangeMock} />);
fireEvent.change(screen.getByPlaceholderText(/enter delay/i), {
target: { value: '3' },
});
fireEvent.click(screen.getByText(/schedule reboot/i));
expect(screen.getByText(/reboot has been scheduled/i)).toBeInTheDocument();
vi.advanceTimersByTime(3000);
await waitFor(() => {
expect(rebootOtaMock).toHaveBeenCalledWith(0);
expect(onOpenChangeMock).toHaveBeenCalledWith(false);
});
});
it('triggers an instant reboot', async () => {
const onOpenChangeMock = vi.fn();
render(<RebootOTADialog open={true} onOpenChange={onOpenChangeMock} />);
fireEvent.click(screen.getByText(/reboot to ota mode now/i));
await waitFor(() => {
expect(rebootOtaMock).toHaveBeenCalledWith(5);
expect(onOpenChangeMock).toHaveBeenCalledWith(false);
});
});
it('does not call reboot if connection is undefined', async () => {
const onOpenChangeMock = vi.fn();
// simulate no connection
mockConnection = undefined;
render(<RebootOTADialog open={true} onOpenChange={onOpenChangeMock} />);
fireEvent.click(screen.getByText(/schedule reboot/i));
vi.advanceTimersByTime(5000);
await waitFor(() => {
expect(rebootOtaMock).not.toHaveBeenCalled();
expect(onOpenChangeMock).not.toHaveBeenCalled();
});
// reset connection for other tests
mockConnection = { rebootOta: rebootOtaMock };
});
});

View File

@@ -0,0 +1,104 @@
import { useState } from "react";
import { ClockIcon, RefreshCwIcon } from "lucide-react";
import { Button } from "@components/UI/Button.tsx";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { Input } from "@components/UI/Input.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
export interface RebootOTADialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
const DEFAULT_REBOOT_DELAY = 5; // seconds
export const RebootOTADialog = ({ open, onOpenChange }: RebootOTADialogProps) => {
const { connection } = useDevice();
const [time, setTime] = useState<number>(DEFAULT_REBOOT_DELAY);
const [isScheduled, setIsScheduled] = useState(false);
const [inputValue, setInputValue] = useState(DEFAULT_REBOOT_DELAY.toString());
const handleSetTime = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.validity.valid) {
e.preventDefault();
return
};
const val = e.target.value;
setInputValue(val);
const parsed = Number(val);
if (!isNaN(parsed) && parsed > 0) {
setTime(parsed);
}
};
const handleRebootWithTimeout = async () => {
if (!connection) return;
setIsScheduled(true);
const delay = time > 0 ? time : DEFAULT_REBOOT_DELAY;
await new Promise<void>((resolve) => {
setTimeout(() => {
console.log("Rebooting...");
resolve();
}, delay * 1000);
}).finally(() => {
setIsScheduled(false);
onOpenChange(false);
setInputValue(DEFAULT_REBOOT_DELAY.toString());
});
connection.rebootOta(0);
};
const handleInstantReboot = async () => {
if (!connection) return;
await connection.rebootOta(DEFAULT_REBOOT_DELAY);
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>Reboot to OTA Mode</DialogTitle>
<DialogDescription>
Reboot the connected node after a delay into OTA (Over-the-Air) mode.
</DialogDescription>
</DialogHeader>
<div className="flex gap-2 p-2 items-center relative">
<Input
type="number"
min={1}
max={86400}
className="dark:text-slate-900 appearance-none"
value={inputValue}
onChange={handleSetTime}
placeholder="Enter delay (sec)"
/>
<Button onClick={() => handleRebootWithTimeout()} className="w-9/12">
<ClockIcon className="mr-2" size={18} />
{isScheduled ? 'Reboot has been scheduled' : 'Schedule Reboot'}
</Button>
</div>
<Button variant="destructive" onClick={() => handleInstantReboot()}>
<RefreshCwIcon className="mr-2" size={16} />
Reboot to OTA Mode Now
</Button>
</DialogContent>
</Dialog>
);
};

View File

@@ -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();
});
});

View File

@@ -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 >
);
};

View File

@@ -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);
});
});

View File

@@ -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
};
}

View File

@@ -3,6 +3,7 @@ import { useDevice } from "@core/stores/deviceStore.ts";
import { Button } from "@components/UI/Button.tsx";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
@@ -32,6 +33,7 @@ export const RemoveNodeDialog = ({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>Remove Node?</DialogTitle>
<DialogDescription>

View File

@@ -1,6 +1,7 @@
import { Button } from "@components/UI/Button.tsx";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
@@ -27,6 +28,7 @@ export const ShutdownDialog = ({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>Schedule Shutdown</DialogTitle>
<DialogDescription>

View File

@@ -1,6 +1,7 @@
import { useDevice } from "../../core/stores/deviceStore.ts";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
@@ -36,6 +37,7 @@ export const TracerouteResponseDialog = ({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>{`Traceroute: ${longName} (${shortName})`}</DialogTitle>
</DialogHeader>

View File

@@ -0,0 +1,91 @@
// deno-lint-ignore-file
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx";
import { eventBus } from "@core/utils/eventBus.ts";
import { DeviceWrapper } from "@app/DeviceWrapper.tsx";
describe("UnsafeRolesDialog", () => {
const mockDevice = {
setDialogOpen: vi.fn(),
};
const renderWithDeviceContext = (ui: any) => {
return render(
<DeviceWrapper device={mockDevice}>
{ui}
</DeviceWrapper>
);
};
it("renders the dialog when open is true", () => {
renderWithDeviceContext(<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />);
const dialog = screen.getByRole('dialog');
expect(dialog).toBeInTheDocument();
expect(screen.getByText(/I have read the/i)).toBeInTheDocument();
expect(screen.getByText(/understand the implications/i)).toBeInTheDocument();
const links = screen.getAllByRole('link');
expect(links).toHaveLength(2);
expect(links[0]).toHaveTextContent('Device Role Documentation');
expect(links[1]).toHaveTextContent('Choosing The Right Device Role');
});
it("displays the correct links", () => {
renderWithDeviceContext(<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />);
const docLink = screen.getByRole("link", { name: /Device Role Documentation/i });
const blogLink = screen.getByRole("link", { name: /Choosing The Right Device Role/i });
expect(docLink).toHaveAttribute("href", "https://meshtastic.org/docs/configuration/radio/device/");
expect(blogLink).toHaveAttribute("href", "https://meshtastic.org/blog/choosing-the-right-device-role/");
});
it("does not allow confirmation until checkbox is checked", () => {
renderWithDeviceContext(<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />);
const confirmButton = screen.getByRole("button", { name: /confirm/i });
expect(confirmButton).toBeDisabled();
const checkbox = screen.getByRole("checkbox");
fireEvent.click(checkbox);
expect(confirmButton).toBeEnabled();
});
it("emits the correct event when closing via close button", () => {
const eventSpy = vi.spyOn(eventBus, "emit");
renderWithDeviceContext(<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />);
const dismissButton = screen.getByRole("button", { name: /close/i });
fireEvent.click(dismissButton);
expect(eventSpy).toHaveBeenCalledWith("dialog:unsafeRoles", { action: "dismiss" });
});
it("emits the correct event when dismissing", () => {
const eventSpy = vi.spyOn(eventBus, "emit");
renderWithDeviceContext(<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />);
const dismissButton = screen.getByRole("button", { name: /dismiss/i });
fireEvent.click(dismissButton);
expect(eventSpy).toHaveBeenCalledWith("dialog:unsafeRoles", { action: "dismiss" });
});
it("emits the correct event when confirming", () => {
const eventSpy = vi.spyOn(eventBus, "emit");
renderWithDeviceContext(<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />);
const checkbox = screen.getByRole("checkbox");
const confirmButton = screen.getByRole("button", { name: /confirm/i });
fireEvent.click(checkbox);
fireEvent.click(confirmButton);
expect(eventSpy).toHaveBeenCalledWith("dialog:unsafeRoles", { action: "confirm" });
});
});

View File

@@ -0,0 +1,71 @@
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { Link } from "@components/UI/Typography/Link.tsx";
import { Checkbox } from "@components/UI/Checkbox/index.tsx";
import { Button } from "@components/UI/Button.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useState } from "react";
import { eventBus } from "@core/utils/eventBus.ts";
export interface RouterRoleDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const UnsafeRolesDialog = ({ open, onOpenChange }: RouterRoleDialogProps) => {
const [confirmState, setConfirmState] = useState(false);
const { setDialogOpen } = useDevice();
const deviceRoleLink = "https://meshtastic.org/docs/configuration/radio/device/";
const choosingTheRightDeviceRoleLink = "https://meshtastic.org/blog/choosing-the-right-device-role/";
const handleCloseDialog = (action: 'confirm' | 'dismiss') => {
setDialogOpen('unsafeRoles', false);
setConfirmState(false);
eventBus.emit('dialog:unsafeRoles', { action });
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-8 flex flex-col">
<DialogClose onClick={() => handleCloseDialog('dismiss')} />
<DialogHeader>
<DialogTitle>Are you sure?</DialogTitle>
</DialogHeader>
<DialogDescription className="text-md">
I have read the <Link href={deviceRoleLink} className="">Device Role Documentation</Link>{" "}
and the blog post about <Link href={choosingTheRightDeviceRoleLink}>Choosing The Right Device Role</Link> and understand the implications of changing the role.
</DialogDescription>
<div className="flex items-center gap-2">
<Checkbox
id="routerRole"
checked={confirmState}
onChange={() => setConfirmState(!confirmState)}
>
Yes, I know what I'm doing
</Checkbox>
</div>
<DialogFooter className="mt-6">
<Button
variant="default"
name="dismiss"
onClick={() => handleCloseDialog('dismiss')}> Dismiss
</Button>
<Button
variant="default"
name="confirm"
disabled={!confirmState}
onClick={() => handleCloseDialog('confirm')}> Confirm
</Button>
</DialogFooter>
</DialogContent>
</Dialog >
);
};

View File

@@ -0,0 +1,117 @@
import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest';
import { renderHook } from '@testing-library/react';
import { useUnsafeRolesDialog, UNSAFE_ROLES } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog";
import { eventBus } from "@core/utils/eventBus";
vi.mock('@core/utils/eventBus', () => ({
eventBus: {
on: vi.fn(),
off: vi.fn(),
emit: vi.fn(),
},
}));
const mockDevice = {
setDialogOpen: vi.fn(),
};
vi.mock('@core/stores/deviceStore', () => ({
useDevice: () => ({
setDialogOpen: mockDevice.setDialogOpen,
}),
}));
describe('useUnsafeRolesDialog', () => {
beforeEach(() => {
vi.resetAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
const renderUnsafeRolesHook = () => {
return renderHook(() => useUnsafeRolesDialog());
};
describe('handleCloseDialog', () => {
it('should call setDialogOpen with correct parameters when dialog is closed', () => {
const { result } = renderUnsafeRolesHook();
result.current.handleCloseDialog();
expect(mockDevice.setDialogOpen).toHaveBeenCalledWith('unsafeRoles', false);
});
});
describe('validateRoleSelection', () => {
it('should resolve with true for safe roles without opening dialog', async () => {
const { result } = renderUnsafeRolesHook();
const safeRole = 'SAFE_ROLE';
const validationResult = await result.current.validateRoleSelection(safeRole);
expect(validationResult).toBe(true);
expect(mockDevice.setDialogOpen).not.toHaveBeenCalled();
});
it('should open dialog for unsafe roles and resolve with true when confirmed', async () => {
const { result } = renderUnsafeRolesHook();
const validationPromise = result.current.validateRoleSelection(UNSAFE_ROLES[0]);
expect(mockDevice.setDialogOpen).toHaveBeenCalledWith('unsafeRoles', true);
expect(eventBus.on).toHaveBeenCalledWith('dialog:unsafeRoles', expect.any(Function));
const onHandler = (eventBus.on as Mock).mock.calls[0][1];
onHandler({ action: 'confirm' });
const validationResult = await validationPromise;
expect(validationResult).toBe(true);
expect(eventBus.off).toHaveBeenCalledWith('dialog:unsafeRoles', onHandler);
});
it('should resolve with false when user dismisses the dialog', async () => {
const { result } = renderUnsafeRolesHook();
const validationPromise = result.current.validateRoleSelection(UNSAFE_ROLES[0]);
const onHandler = (eventBus.on as Mock).mock.calls[0][1];
onHandler({ action: 'dismiss' });
const validationResult = await validationPromise;
expect(validationResult).toBe(false);
expect(eventBus.off).toHaveBeenCalledWith('dialog:unsafeRoles', onHandler);
});
it('should clean up event listener after response', async () => {
const { result } = renderUnsafeRolesHook();
const validationPromise = result.current.validateRoleSelection(UNSAFE_ROLES[1]);
const onHandler = (eventBus.on as Mock).mock.calls[0][1];
onHandler({ action: 'confirm' });
await validationPromise;
expect(eventBus.off).toHaveBeenCalledWith('dialog:unsafeRoles', onHandler);
});
});
it('should work with all unsafe roles', async () => {
const { result } = renderUnsafeRolesHook();
for (const unsafeRole of UNSAFE_ROLES) {
mockDevice.setDialogOpen.mockClear();
(eventBus.on as Mock).mockClear();
const validationPromise = result.current.validateRoleSelection(unsafeRole);
expect(mockDevice.setDialogOpen).toHaveBeenCalledWith('unsafeRoles', true);
const onHandler = (eventBus.on as Mock).mock.calls[0][1];
onHandler({ action: 'confirm' });
const validationResult = await validationPromise;
expect(validationResult).toBe(true);
}
});
});

View File

@@ -0,0 +1,39 @@
import { useCallback } from "react";
import { eventBus } from "@core/utils/eventBus.ts";
import { useDevice } from "@core/stores/deviceStore.ts";
export const UNSAFE_ROLES = ["ROUTER", "REPEATER"];
export type UnsafeRole = typeof UNSAFE_ROLES[number];
export const useUnsafeRolesDialog = () => {
const { setDialogOpen } = useDevice();
const handleCloseDialog = useCallback(() => {
setDialogOpen("unsafeRoles", false);
}, [setDialogOpen]);
const validateRoleSelection = useCallback(
(newRoleKey: string): Promise<boolean> => {
if (!UNSAFE_ROLES.includes(newRoleKey as UnsafeRole)) {
return Promise.resolve(true);
}
setDialogOpen("unsafeRoles", true);
return new Promise((resolve) => {
const handleResponse = ({ action }: { action: "confirm" | "dismiss" }) => {
eventBus.off("dialog:unsafeRoles", handleResponse);
resolve(action === "confirm");
};
eventBus.on("dialog:unsafeRoles", handleResponse);
});
},
[setDialogOpen]
);
return {
handleCloseDialog,
validateRoleSelection,
};
};

View File

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

View File

@@ -7,7 +7,7 @@ import type { LucideIcon } from "lucide-react";
import { Eye, EyeOff } from "lucide-react";
import type { ChangeEventHandler } from "react";
import { useState } from "react";
import { Controller, type FieldValues } from "react-hook-form";
import { useController, type FieldValues } from "react-hook-form";
export interface InputFieldProps<T> extends BaseFormBuilderProps<T> {
type: "text" | "number" | "password";
@@ -17,6 +17,12 @@ export interface InputFieldProps<T> extends BaseFormBuilderProps<T> {
prefix?: string;
suffix?: string;
step?: number;
fieldLength?: {
min?: number;
max?: number;
currentValueLength?: number;
showCharacterCount?: boolean;
},
action?: {
icon: LucideIcon;
onClick: () => void;
@@ -29,42 +35,59 @@ export function GenericInput<T extends FieldValues>({
disabled,
field,
}: GenericFormElementProps<T, InputFieldProps<T>>) {
const { fieldLength, ...restProperties } = field.properties || {};
const [passwordShown, setPasswordShown] = useState(false);
const [currentLength, setCurrentLength] = useState<number>(fieldLength?.currentValueLength || 0);
const { field: controllerField } = useController({
name: field.name,
control,
});
const togglePasswordVisiblity = () => {
setPasswordShown(!passwordShown);
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
if (field.properties?.fieldLength?.max && newValue.length > field.properties?.fieldLength?.max) {
return;
}
setCurrentLength(newValue.length);
if (field.inputChange) field.inputChange(e);
controllerField.onChange(field.type === "number" ? Number.parseFloat(newValue).toString() : newValue);
};
return (
<Controller
name={field.name}
control={control}
render={({ field: { value, onChange, ...rest } }) => (
<Input
type={field.type === "password" && passwordShown
? "text"
: field.type}
action={field.type === "password"
<div className="relative w-full">
<Input
type={field.type === "password" && passwordShown ? "text" : field.type}
action={
field.type === "password"
? {
icon: passwordShown ? EyeOff : Eye,
onClick: togglePasswordVisiblity,
}
: undefined}
step={field.properties?.step}
value={field.type === "number" ? Number.parseFloat(value) : value}
id={field.name}
onChange={(e) => {
if (field.inputChange) field.inputChange(e);
onChange(
field.type === "number"
? Number.parseFloat(e.target.value)
: e.target.value,
);
}}
{...field.properties}
{...rest}
disabled={disabled}
/>
: undefined
}
step={field.properties?.step}
value={field.type === "number" ? String(controllerField.value) : controllerField.value}
id={field.name}
onChange={handleInputChange}
{...restProperties}
disabled={disabled}
/>
{fieldLength?.showCharacterCount && fieldLength?.max && (
<div className="absolute inset-y-0 right-0 flex items-center pr-3 text-sm text-slate-500 dark:text-slate-400">
{currentLength ?? fieldLength?.currentValueLength}/{fieldLength?.max}
</div>
)}
/>
</div>
);
}

View File

@@ -19,28 +19,32 @@ export interface MultiSelectFieldProps<T> extends BaseFormBuilderProps<T> {
};
}
const formatEnumDisplay = (name: string): string => {
return name
.replace(/_/g, " ")
.toLowerCase()
.split(" ")
.map((s) => s.charAt(0).toUpperCase() + s.substring(1))
.join(" ");
};
export function MultiSelectInput<T extends FieldValues>({
field,
}: GenericFormElementProps<T, MultiSelectFieldProps<T>>) {
const { enumValue, formatEnumName, ...remainingProperties } =
field.properties;
// Make sure to filter out the UNSET value, as it shouldn't be shown in the UI
const optionsEnumValues = enumValue
? Object.entries(enumValue)
.filter((value) => typeof value[1] === "number")
.filter((value) => value[0] !== "UNSET")
: [];
const valueToKeyMap: Record<string, string> = {};
const optionsEnumValues: [string, number][] = [];
const formatName = (name: string) => {
if (!formatEnumName) return name;
return name
.replace(/_/g, " ")
.toLowerCase()
.split(" ")
.map((s) => s.charAt(0).toUpperCase() + s.substring(1))
.join(" ");
};
if (enumValue) {
Object.entries(enumValue).forEach(([key, val]) => {
if (typeof val === "number" && key !== "UNSET") {
valueToKeyMap[val.toString()] = key;
optionsEnumValues.push([key, val as number]);
}
});
}
return (
<MultiSelect {...remainingProperties}>
@@ -52,9 +56,9 @@ export function MultiSelectInput<T extends FieldValues>({
checked={field.isChecked(name)}
onCheckedChange={() => field.onValueChange(name)}
>
{formatEnumName ? formatName(name) : name}
{formatEnumName ? formatEnumDisplay(name) : name}
</MultiSelectItem>
))}
</MultiSelect>
);
}
}

View File

@@ -9,12 +9,15 @@ import {
SelectTrigger,
SelectValue,
} from "@components/UI/Select.tsx";
import { Controller, type FieldValues } from "react-hook-form";
import { useController, type FieldValues } from "react-hook-form";
export interface SelectFieldProps<T> extends BaseFormBuilderProps<T> {
type: "select";
selectChange?: (e: string) => void;
selectChange?: (e: string, name: string) => void;
validate?: (newValue: string) => Promise<boolean>;
defaultValue?: string;
properties: BaseFormBuilderProps<T>["properties"] & {
defaultValue?: T;
enumValue: {
[s: string]: string | number;
};
@@ -22,56 +25,70 @@ export interface SelectFieldProps<T> extends BaseFormBuilderProps<T> {
};
}
const formatEnumDisplay = (name: string): string => {
return name
.replace(/_/g, " ")
.toLowerCase()
.split(" ")
.map((s) => s.charAt(0).toUpperCase() + s.substring(1))
.join(" ");
};
export function SelectInput<T extends FieldValues>({
control,
disabled,
field,
}: GenericFormElementProps<T, SelectFieldProps<T>>) {
const {
field: { value, onChange, ...rest },
} = useController({
name: field.name,
control,
});
const { enumValue, formatEnumName, ...remainingProperties } = field.properties;
const valueToKeyMap: Record<string, string> = {};
const optionsEnumValues: [string, number][] = [];
if (enumValue) {
Object.entries(enumValue).forEach(([key, val]) => {
if (typeof val === "number") {
valueToKeyMap[val.toString()] = key;
optionsEnumValues.push([key, val]);
}
});
}
const handleValueChange = async (newValue: string) => {
const selectedKey = valueToKeyMap[newValue];
if (field.validate) {
const isValid = await field.validate(selectedKey);
if (!isValid) return;
}
if (field.selectChange) field.selectChange(newValue, selectedKey);
onChange(Number.parseInt(newValue));
};
return (
<Controller
name={field.name}
control={control}
render={({ field: { value, onChange, ...rest } }) => {
const { enumValue, formatEnumName, ...remainingProperties } =
field.properties;
const optionsEnumValues = enumValue
? Object.entries(enumValue).filter(
(value) => typeof value[1] === "number",
)
: [];
return (
<Select
onValueChange={(e) => {
if (field.selectChange) field.selectChange(e);
onChange(Number.parseInt(e));
}}
disabled={disabled}
value={value?.toString()}
{...remainingProperties}
{...rest}
>
<SelectTrigger id={field.name}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{optionsEnumValues.map(([name, value]) => (
<SelectItem key={name} value={value.toString()}>
{formatEnumName
? name
.replace(/_/g, " ")
.toLowerCase()
.split(" ")
.map((s) =>
s.charAt(0).toUpperCase() + s.substring(1)
)
.join(" ")
: name}
</SelectItem>
))}
</SelectContent>
</Select>
);
}}
/>
<Select
onValueChange={handleValueChange}
disabled={disabled}
value={value?.toString()}
{...remainingProperties}
{...rest}
>
<SelectTrigger id={field.name}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{optionsEnumValues.map(([name, val]) => (
<SelectItem key={name} value={val.toString()}>
{formatEnumName ? formatEnumDisplay(name) : name}
</SelectItem>
))}
</SelectContent>
</Select>
);
}

View File

@@ -5,15 +5,10 @@ export const KeyBackupReminder = () => {
const { setDialogOpen } = useDevice();
useBackupReminder({
reminderInDays: 7,
message:
"We recommend backing up your key data regularly. Would you like to back up now?",
onAccept: () => setDialogOpen("pkiBackup", true),
enabled: true,
cookieOptions: {
secure: true,
sameSite: "strict",
},
});
// deno-lint-ignore jsx-no-useless-fragment
return <></>;

View File

@@ -34,13 +34,10 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
settings: {
...data.settings,
psk: toByteArray(pass),
moduleSettings: {
positionPrecision: data.settings.positionEnabled
? data.settings.preciseLocation
? 32
: data.settings.positionPrecision
: 0,
},
moduleSettings: create(Protobuf.Channel.ModuleSettingsSchema, {
...data.settings.moduleSettings,
positionPrecision: data.settings.moduleSettings.positionPrecision,
}),
},
});
connection?.setChannel(channel).then(() => {
@@ -100,17 +97,9 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
settings: {
...channel?.settings,
psk: pass,
positionEnabled:
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,
moduleSettings: {...channel?.settings?.moduleSettings,
positionPrecision: channel?.settings?.moduleSettings?.positionPrecision === undefined ? 10 : channel?.settings?.moduleSettings?.positionPrecision,
}
},
},
}}
@@ -135,6 +124,7 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
{
type: "passwordGenerator",
name: "settings.psk",
id: 'channel-psk',
label: "Pre-Shared Key",
description:
"Supported PSK lengths: 256-bit, 128-bit, 8-bit, Empty (0-bit)",
@@ -173,39 +163,30 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
label: "Downlink Enabled",
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",
name: "settings.positionPrecision",
label: "Approximate Location",
name: "settings.moduleSettings.positionPrecision",
label: "Location",
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: {
enumValue: config.display?.units === 0
? {
"Within 23 km": 10,
"Within 12 km": 11,
"Within 5.8 km": 12,
"Within 2.9 km": 13,
"Within 1.5 km": 14,
"Within 700 m": 15,
"Within 350 m": 16,
"Within 200 m": 17,
"Within 90 m": 18,
"Within 50 m": 19,
"Do not share location": 0,
"Within 23 kilometers": 10,
"Within 12 kilometers": 11,
"Within 5.8 kilometers": 12,
"Within 2.9 kilometers": 13,
"Within 1.5 kilometers": 14,
"Within 700 meters": 15,
"Within 350 meters": 16,
"Within 200 meters": 17,
"Within 90 meters": 18,
"Within 50 meters": 19,
"Precise Location": 32,
}
: {
"Do not share location": 0,
"Within 15 miles": 10,
"Within 7.3 miles": 11,
"Within 3.6 miles": 12,
@@ -216,6 +197,7 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
"Within 600 feet": 17,
"Within 300 feet": 18,
"Within 150 feet": 19,
"Precise Location": 32,
},
},
},

View File

@@ -0,0 +1,129 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { Device } from '@components/PageComponents/Config/Device/index.tsx';
import { useDevice } from "@core/stores/deviceStore.ts";
import { useUnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
import { Protobuf } from "@meshtastic/core";
vi.mock('@core/stores/deviceStore', () => ({
useDevice: vi.fn()
}));
vi.mock('@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog', () => ({
useUnsafeRolesDialog: vi.fn()
}));
// Mock the DynamicForm component since we're testing the Device component,
// not the DynamicForm implementation
vi.mock('@components/Form/DynamicForm', () => ({
DynamicForm: vi.fn(({ onSubmit }) => {
// Render a simplified version of the form for testing
return (
<div data-testid="dynamic-form">
<select
data-testid="role-select"
onChange={(e) => {
// Simulate the validation and submission process
const mockData = { role: e.target.value };
onSubmit(mockData);
}}
>
{Object.entries(Protobuf.Config.Config_DeviceConfig_Role).map(([key, value]) => (
<option key={key} value={value}>
{key}
</option>
))}
</select>
<button type="submit"
data-testid="submit-button"
onClick={() => onSubmit({ role: "CLIENT" })}
>
Submit
</button>
</div>
);
})
}));
describe('Device component', () => {
const setWorkingConfigMock = vi.fn();
const validateRoleSelectionMock = vi.fn();
const mockDeviceConfig = {
role: "CLIENT",
buttonGpio: 0,
buzzerGpio: 0,
rebroadcastMode: "ALL",
nodeInfoBroadcastSecs: 300,
doubleTapAsButtonPress: false,
disableTripleClick: false,
ledHeartbeatDisabled: false,
};
beforeEach(() => {
vi.resetAllMocks();
// Mock the useDevice hook
(useDevice as any).mockReturnValue({
config: {
device: mockDeviceConfig
},
setWorkingConfig: setWorkingConfigMock
});
// Mock the useUnsafeRolesDialog hook
validateRoleSelectionMock.mockResolvedValue(true);
(useUnsafeRolesDialog as any).mockReturnValue({
validateRoleSelection: validateRoleSelectionMock
});
});
afterEach(() => {
vi.clearAllMocks();
});
it('should render the Device form', () => {
render(<Device />);
expect(screen.getByTestId('dynamic-form')).toBeInTheDocument();
});
it('should use the validateRoleSelection from the unsafe roles hook', () => {
render(<Device />);
expect(useUnsafeRolesDialog).toHaveBeenCalled();
});
it('should call setWorkingConfig when form is submitted', async () => {
render(<Device />);
fireEvent.click(screen.getByTestId('submit-button'));
await waitFor(() => {
expect(setWorkingConfigMock).toHaveBeenCalledWith(
expect.objectContaining({
payloadVariant: {
case: "device",
value: expect.objectContaining({ role: "CLIENT" })
}
})
);
});
});
it('should create config with proper structure', async () => {
render(<Device />);
// Simulate form submission
fireEvent.click(screen.getByTestId('submit-button'));
await waitFor(() => {
expect(setWorkingConfigMock).toHaveBeenCalledWith(
expect.objectContaining({
payloadVariant: {
case: "device",
value: expect.any(Object)
}
})
);
});
});
});

View File

@@ -1,11 +1,13 @@
import type { DeviceValidation } from "@app/validation/config/device.tsx";
import type { DeviceValidation } from "@app/validation/config/device.ts";
import { create } from "@bufbuild/protobuf";
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core";
import { useUnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
export const Device = () => {
const { config, setWorkingConfig } = useDevice();
const { validateRoleSelection } = useUnsafeRolesDialog();
const onSubmit = (data: DeviceValidation) => {
setWorkingConfig(
@@ -14,10 +16,9 @@ export const Device = () => {
case: "device",
value: data,
},
}),
})
);
};
return (
<DynamicForm<DeviceValidation>
onSubmit={onSubmit}
@@ -32,23 +33,9 @@ export const Device = () => {
name: "role",
label: "Role",
description: "What role the device performs on the mesh",
validate: validateRoleSelection,
properties: {
enumValue: {
Client: Protobuf.Config.Config_DeviceConfig_Role.CLIENT,
"Client Mute":
Protobuf.Config.Config_DeviceConfig_Role.CLIENT_MUTE,
Router: Protobuf.Config.Config_DeviceConfig_Role.ROUTER,
Repeater: Protobuf.Config.Config_DeviceConfig_Role.REPEATER,
Tracker: Protobuf.Config.Config_DeviceConfig_Role.TRACKER,
Sensor: Protobuf.Config.Config_DeviceConfig_Role.SENSOR,
TAK: Protobuf.Config.Config_DeviceConfig_Role.TAK,
"Client Hidden":
Protobuf.Config.Config_DeviceConfig_Role.CLIENT_HIDDEN,
"Lost and Found":
Protobuf.Config.Config_DeviceConfig_Role.LOST_AND_FOUND,
"TAK Tracker":
Protobuf.Config.Config_DeviceConfig_Role.TAK_TRACKER,
},
enumValue: Protobuf.Config.Config_DeviceConfig_Role,
formatEnumName: true,
},
},
@@ -95,6 +82,19 @@ export const Device = () => {
label: "Disable Triple Click",
description: "Disable triple click",
},
{
type: 'text',
name: 'tzdef',
label: 'POSIX Timezone',
description: 'The POSIX timezone string for the device',
properties: {
fieldLength: {
max: 64,
currentValueLength: config.device?.tzdef?.length,
showCharacterCount: true,
}
},
},
{
type: "toggle",
name: "ledHeartbeatDisabled",

View File

@@ -0,0 +1,283 @@
// import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// import { render, screen, fireEvent, waitFor } from '@testing-library/react';
// import { Network } from '@components/PageComponents/Config/Network/index.tsx';
// import { useDevice } from "@core/stores/deviceStore.ts";
// import { Protobuf } from "@meshtastic/core";
// vi.mock('@core/stores/deviceStore', () => ({
// useDevice: vi.fn()
// }));
// vi.mock('@components/Form/DynamicForm', () => ({
// DynamicForm: vi.fn(({ onSubmit }) => {
// return (
// <div data-testid="dynamic-form">
// <select
// data-testid="role-select"
// onChange={(e) => {
// const mockData = { role: e.target.value };
// onSubmit(mockData);
// }}
// >
// {Object.entries(Protobuf.Config.Config_DeviceConfig_Role).map(([key, value]) => (
// <option key={key} value={value}>
// {key}
// </option>
// ))}
// </select>
// <button type="submit"
// data-testid="submit-button"
// onClick={() => onSubmit({ role: "CLIENT" })}
// >
// Submit
// </button>
// </div>
// );
// })
// }));
// describe('Network component', () => {
// const setWorkingConfigMock = vi.fn();
// const mockDeviceConfig = {
// role: "CLIENT",
// buttonGpio: 0,
// buzzerGpio: 0,
// rebroadcastMode: "ALL",
// nodeInfoBroadcastSecs: 300,
// doubleTapAsButtonPress: false,
// disableTripleClick: false,
// ledHeartbeatDisabled: false,
// };
// beforeEach(() => {
// vi.resetAllMocks();
// (useDevice as any).mockReturnValue({
// config: {
// device: mockDeviceConfig
// },
// setWorkingConfig: setWorkingConfigMock
// });
// });
// afterEach(() => {
// vi.clearAllMocks();
// });
// it('should render the Network form', () => {
// render(<Network />);
// expect(screen.getByTestId('dynamic-form')).toBeInTheDocument();
// });
// it('should call setWorkingConfig when form is submitted', async () => {
// render(<Network />);
// fireEvent.click(screen.getByTestId('submit-button'));
// await waitFor(() => {
// expect(setWorkingConfigMock).toHaveBeenCalledWith(
// expect.objectContaining({
// payloadVariant: {
// case: "device",
// value: expect.objectContaining({ role: "CLIENT" })
// }
// })
// );
// });
// });
// it('should create config with proper structure', async () => {
// render(<Network />);
// // Simulate form submission
// fireEvent.click(screen.getByTestId('submit-button'));
// await waitFor(() => {
// expect(setWorkingConfigMock).toHaveBeenCalledWith(
// expect.objectContaining({
// payloadVariant: {
// case: "network",
// value: expect.any(Object)
// }
// })
// );
// });
// });
// });
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { Network } from '@components/PageComponents/Config/Network/index.tsx';
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core";
vi.mock('@core/stores/deviceStore', () => ({
useDevice: vi.fn()
}));
vi.mock('@components/Form/DynamicForm', async () => {
const React = await import('react');
const { useState } = React;
return {
DynamicForm: ({ onSubmit, defaultValues }: any) => {
const [wifiEnabled, setWifiEnabled] = useState(defaultValues.wifiEnabled ?? false);
const [ssid, setSsid] = useState(defaultValues.wifiSsid ?? '');
const [psk, setPsk] = useState(defaultValues.wifiPsk ?? '');
return (
<form
onSubmit={(e) => {
e.preventDefault();
onSubmit({
...defaultValues,
wifiEnabled,
wifiSsid: ssid,
wifiPsk: psk,
});
}}
data-testid="dynamic-form"
>
<input
type="checkbox"
aria-label="WiFi Enabled"
checked={wifiEnabled}
onChange={(e) => setWifiEnabled(e.target.checked)}
/>
<input
aria-label="SSID"
value={ssid}
onChange={(e) => setSsid(e.target.value)}
disabled={!wifiEnabled}
/>
<input
aria-label="PSK"
value={psk}
onChange={(e) => setPsk(e.target.value)}
disabled={!wifiEnabled}
/>
<button type="submit" data-testid="submit-button">
Submit
</button>
</form>
);
},
};
});
;
describe('Network component', () => {
const setWorkingConfigMock = vi.fn();
const mockNetworkConfig = {
wifiEnabled: false,
wifiSsid: '',
wifiPsk: '',
ntpServer: '',
ethEnabled: false,
addressMode: Protobuf.Config.Config_NetworkConfig_AddressMode.DHCP,
ipv4Config: {
ip: 0,
gateway: 0,
subnet: 0,
dns: 0,
},
enabledProtocols:
Protobuf.Config.Config_NetworkConfig_ProtocolFlags.NO_BROADCAST,
rsyslogServer: '',
};
beforeEach(() => {
vi.resetAllMocks();
(useDevice as any).mockReturnValue({
config: {
network: mockNetworkConfig
},
setWorkingConfig: setWorkingConfigMock
});
});
afterEach(() => {
vi.clearAllMocks();
});
it('should render the Network form', () => {
render(<Network />);
expect(screen.getByTestId('dynamic-form')).toBeInTheDocument();
});
it('should disable SSID and PSK fields when wifi is off', () => {
render(<Network />);
expect(screen.getByLabelText("SSID")).toBeDisabled();
expect(screen.getByLabelText("PSK")).toBeDisabled();
});
it('should enable SSID and PSK when wifi is toggled on', async () => {
render(<Network />);
const toggle = screen.getByLabelText("WiFi Enabled");
screen.debug()
fireEvent.click(toggle); // turns wifiEnabled = true
await waitFor(() => {
expect(screen.getByLabelText("SSID")).not.toBeDisabled();
expect(screen.getByLabelText("PSK")).not.toBeDisabled();
});
});
it('should call setWorkingConfig with the right structure on submit', async () => {
render(<Network />);
fireEvent.click(screen.getByTestId("submit-button"));
await waitFor(() => {
expect(setWorkingConfigMock).toHaveBeenCalledWith(
expect.objectContaining({
payloadVariant: {
case: "network",
value: expect.objectContaining({
wifiEnabled: false,
wifiSsid: '',
wifiPsk: '',
ntpServer: '',
ethEnabled: false,
rsyslogServer: '',
})
}
})
);
});
});
it('should submit valid data after enabling wifi and entering SSID and PSK', async () => {
render(<Network />);
fireEvent.click(screen.getByLabelText("WiFi Enabled"));
fireEvent.change(screen.getByLabelText("SSID"), {
target: { value: "MySSID" }
});
fireEvent.change(screen.getByLabelText("PSK"), {
target: { value: "MySecretPSK" }
});
fireEvent.click(screen.getByTestId("submit-button"));
await waitFor(() => {
expect(setWorkingConfigMock).toHaveBeenCalledWith(
expect.objectContaining({
payloadVariant: {
case: "network",
value: expect.objectContaining({
wifiEnabled: true,
wifiSsid: "MySSID",
wifiPsk: "MySecretPSK"
})
}
})
);
});
});
});

View File

@@ -1,4 +1,4 @@
import type { NetworkValidation } from "@app/validation/config/network.tsx";
import { NetworkValidationSchema, type NetworkValidation } from "@app/validation/config/network.ts";
import { create } from "@bufbuild/protobuf";
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
@@ -7,11 +7,18 @@ import {
convertIpAddressToInt,
} from "@core/utils/ip.ts";
import { Protobuf } from "@meshtastic/core";
import { validateSchema } from "@app/validation/validate.ts";
export const Network = () => {
const { config, setWorkingConfig } = useDevice();
const onSubmit = (data: NetworkValidation) => {
const result = validateSchema(NetworkValidationSchema, data);
if (!result.success) {
console.error("Validation errors:", result.errors);
}
setWorkingConfig(
create(Protobuf.Config.ConfigSchema, {
payloadVariant: {
@@ -21,10 +28,10 @@ export const Network = () => {
ipv4Config: create(
Protobuf.Config.Config_NetworkConfig_IpV4ConfigSchema,
{
ip: convertIpAddressToInt(data.ipv4Config.ip) ?? 0,
gateway: convertIpAddressToInt(data.ipv4Config.gateway) ?? 0,
subnet: convertIpAddressToInt(data.ipv4Config.subnet) ?? 0,
dns: convertIpAddressToInt(data.ipv4Config.dns) ?? 0,
ip: convertIpAddressToInt(data.ipv4Config?.ip ?? ""),
gateway: convertIpAddressToInt(data.ipv4Config?.gateway ?? ""),
subnet: convertIpAddressToInt(data.ipv4Config?.subnet ?? ""),
dns: convertIpAddressToInt(data.ipv4Config?.dns ?? ""),
},
),
},
@@ -48,6 +55,8 @@ export const Network = () => {
),
dns: convertIntToIpAddress(config.network?.ipv4Config?.dns ?? 0),
},
enabledProtocols: config.network?.enabledProtocols ?? Protobuf.Config.Config_NetworkConfig_ProtocolFlags.NO_BROADCAST
}}
fieldGroups={[
{
@@ -165,6 +174,22 @@ export const Network = () => {
},
],
},
{
label: "UDP Config",
description: "UDP over Mesh configuration",
fields: [
{
type: "select",
name: "enabledProtocols",
label: "Mesh via UDP",
properties: {
enumValue:
Protobuf.Config.Config_NetworkConfig_ProtocolFlags,
formatEnumName: true,
}
},
],
},
{
label: "NTP Config",
description: "NTP configuration",

View File

@@ -1,8 +1,8 @@
import {
type FlagName,
usePositionFlags,
} from "../../../core/hooks/usePositionFlags.ts";
import type { PositionValidation } from "@app/validation/config/position.tsx";
} from "@core/hooks/usePositionFlags.ts";
import type { PositionValidation } from "@app/validation/config/position.ts";
import { create } from "@bufbuild/protobuf";
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
@@ -12,7 +12,7 @@ import { useCallback } from "react";
export const Position = () => {
const { config, setWorkingConfig } = useDevice();
const { flagsValue, activeFlags, toggleFlag, getAllFlags } = usePositionFlags(
config.position.positionFlags ?? 0,
config?.position?.positionFlags ?? 0,
);
const onSubmit = (data: PositionValidation) => {
@@ -74,7 +74,7 @@ export const Position = () => {
name: "positionFlags",
value: activeFlags,
isChecked: (name: string) =>
activeFlags.includes(name as FlagName),
activeFlags?.includes(name as FlagName) ?? false,
onValueChange: onPositonFlagChange,
label: "Position Flags",
placeholder: "Select position flags...",

View File

@@ -5,7 +5,7 @@ import { useAppStore } from "@core/stores/appStore.ts";
import { useDeviceStore } from "@core/stores/deviceStore.ts";
import { subscribeAll } from "@core/subscriptions.ts";
import { randId } from "@core/utils/randId.ts";
import { BleConnection, Constants } from "@meshtastic/js";
import { BleConnection, ServiceUuid } from "@meshtastic/js";
import { useCallback, useEffect, useState } from "react";
export const BLE = ({ closeDialog }: TabElementProps) => {
@@ -58,7 +58,7 @@ export const BLE = ({ closeDialog }: TabElementProps) => {
onClick={async () => {
await navigator.bluetooth
.requestDevice({
filters: [{ services: [Constants.ServiceUuid] }],
filters: [{ services: [ServiceUuid] }],
})
.then((device) => {
const exists = bleDevices.findIndex((d) => d.id === device.id);

View File

@@ -0,0 +1,96 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { HTTP } from "@components/PageComponents/Connect/HTTP.tsx";
import { MeshDevice } from "@meshtastic/core";
import { TransportHTTP } from "@meshtastic/transport-http";
import { vi, describe, it, expect } from "vitest";
vi.mock("@core/stores/appStore.ts", () => ({
useAppStore: vi.fn(() => ({ setSelectedDevice: vi.fn() })),
}));
vi.mock("@core/stores/deviceStore.ts", () => ({
useDeviceStore: vi.fn(() => ({ addDevice: vi.fn(() => ({ addConnection: vi.fn() })) })),
}));
vi.mock("@core/utils/randId.ts", () => ({
randId: vi.fn(() => "mock-id"),
}));
vi.mock("@meshtastic/transport-http", () => ({
TransportHTTP: {
create: vi.fn(() => Promise.resolve({})),
},
}));
vi.mock("@meshtastic/core", () => ({
MeshDevice: vi.fn(() => ({
configure: vi.fn(),
})),
}));
describe("HTTP Component", () => {
it("renders correctly", () => {
render(<HTTP closeDialog={vi.fn()} />);
expect(screen.getByText("IP Address/Hostname")).toBeInTheDocument();
expect(screen.getByRole("textbox")).toBeInTheDocument();
expect(screen.getByPlaceholderText("000.000.000.000 / meshtastic.local")).toBeInTheDocument();
expect(screen.getByText("Use HTTPS")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Connect" })).toBeInTheDocument();
});
it("allows input field to be updated", () => {
render(<HTTP closeDialog={vi.fn()} />);
const inputField = screen.getByRole("textbox");
fireEvent.change(inputField, { target: { value: 'meshtastic.local' } })
expect(screen.getByPlaceholderText("000.000.000.000 / meshtastic.local")).toBeInTheDocument();
});
it("toggles HTTPS switch and updates prefix", () => {
render(<HTTP closeDialog={vi.fn()} />);
const switchInput = screen.getByRole("switch");
expect(screen.getByText("http://")).toBeInTheDocument();
fireEvent.click(switchInput)
expect(screen.getByText("https://")).toBeInTheDocument();
fireEvent.click(switchInput)
expect(switchInput).not.toBeChecked();
expect(screen.getByText("http://")).toBeInTheDocument();
});
it("enables HTTPS toggle when location protocol is https", () => {
Object.defineProperty(window, "location", {
value: { protocol: "https:" },
writable: true,
});
render(<HTTP closeDialog={vi.fn()} />);
const switchInput = screen.getByRole("switch");
expect(switchInput).toBeChecked();
expect(screen.getByText("https://")).toBeInTheDocument();
});
it.skip("submits form and triggers connection process", async () => {
const closeDialog = vi.fn();
render(<HTTP closeDialog={closeDialog} />);
const button = screen.getByRole("button", { name: "Connect" });
expect(button).not.toBeDisabled();
try {
fireEvent.click(button);
await waitFor(() => {
expect(button).toBeDisabled();
expect(closeDialog).toBeCalled();
expect(TransportHTTP.create).toBeCalled();
expect(MeshDevice).toBeCalled();
});
} catch (e) {
console.error(e)
}
});
});

View File

@@ -10,37 +10,43 @@ import { randId } from "@core/utils/randId.ts";
import { MeshDevice } from "@meshtastic/core";
import { TransportHTTP } from "@meshtastic/transport-http";
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { useForm, useController } from "react-hook-form";
interface FormData {
ip: string;
tls: boolean;
}
export const HTTP = ({ closeDialog }: TabElementProps) => {
const [https, setHTTPS] = useState(false);
const isURLHTTPS = location.protocol === "https:";
const { addDevice } = useDeviceStore();
const { setSelectedDevice } = useAppStore();
const { register, handleSubmit, control } = useForm<{
ip: string;
tls: boolean;
}>({
const { control, handleSubmit, register } = useForm<FormData>({
defaultValues: {
ip: ["client.meshtastic.org", "localhost"].includes(
globalThis.location.hostname,
window.location.hostname,
)
? "meshtastic.local"
: globalThis.location.host,
tls: location.protocol === "https:",
: window.location.host,
tls: isURLHTTPS ? true : false,
},
});
const {
field: { value: tlsValue, onChange: setTLS },
} = useController({ name: "tls", control });
const [connectionInProgress, setConnectionInProgress] = useState(false);
const onSubmit = handleSubmit(async (data) => {
setConnectionInProgress(true);
const id = randId();
const device = addDevice(id);
const transport = await TransportHTTP.create(data.ip, data.tls);
const connection = new MeshDevice(transport, id);
connection.configure();
setSelectedDevice(id);
device.addConnection(connection);
subscribeAll(device, connection);
@@ -50,32 +56,26 @@ export const HTTP = ({ closeDialog }: TabElementProps) => {
return (
<form className="flex w-full flex-col gap-2 p-4" onSubmit={onSubmit}>
<div className="flex h-48 flex-col gap-2">
<Label>IP Address/Hostname</Label>
<Input
prefix={https ? "https://" : "http://"}
placeholder="000.000.000.000 / meshtastic.local"
className="text-slate-900 dark:text-slate-900"
disabled={connectionInProgress}
{...register("ip")}
/>
<Controller
name="tls"
control={control}
render={({ field: { ...rest } }) => (
<>
<Label>Use HTTPS</Label>
<Switch
onCheckedChange={(checked: boolean) => {
checked ? setHTTPS(true) : setHTTPS(false);
}}
disabled={location.protocol === "https:" ||
connectionInProgress}
checked={https}
{...rest}
/>
</>
)}
/>
<div>
<Label>IP Address/Hostname</Label>
<Input
prefix={tlsValue ? "https://" : "http://"}
placeholder="000.000.000.000 / meshtastic.local"
className="text-slate-900 dark:text-slate-900"
disabled={connectionInProgress}
{...register("ip")}
/>
</div>
<div className="flex items-center gap-2 mt-2">
<Switch
onCheckedChange={setTLS}
disabled={isURLHTTPS || connectionInProgress}
checked={isURLHTTPS || tlsValue}
{...register("tls")}
/>
<Label>Use HTTPS</Label>
</div>
</div>
<Button
type="submit"
@@ -86,4 +86,4 @@ export const HTTP = ({ closeDialog }: TabElementProps) => {
</Button>
</form>
);
};
};

View File

@@ -18,9 +18,7 @@ export const Serial = ({ closeDialog }: TabElementProps) => {
setSerialPorts(await navigator?.serial.getPorts());
}, []);
navigator?.serial?.addEventListener("connect", (event) => {
console.log(event);
navigator?.serial?.addEventListener("connect", () => {
updateSerialPortList();
});
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 h-48 flex-col gap-2 overflow-y-auto">
{serialPorts.map((port, index) => {
console.log(port);
const { usbProductId, usbVendorId } = port.getInfo();
return (
<Button

View File

@@ -16,31 +16,53 @@ import {
Dot,
LockIcon,
LockOpenIcon,
MessageSquareIcon,
MountainSnow,
Star,
} from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipPortal,
TooltipProvider,
TooltipTrigger,
} from "@radix-ui/react-tooltip";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDevice } from "@core/stores/deviceStore.ts";
export interface NodeDetailProps {
node: ProtobufType.Mesh.NodeInfo;
}
export const NodeDetail = ({ node }: NodeDetailProps) => {
const { setChatType, setActiveChat } = useAppStore();
const { setActivePage } = useDevice();
const name = node.user?.longName || `!${numberToHexUnpadded(node.num)}`;
const shortName = node.user?.shortName ?? "UNK";
const hwModel = node.user?.hwModel ?? 0;
const hardwareType =
Protobuf.Mesh.HardwareModel[hwModel]?.replaceAll("_", " ") ?? `${hwModel}`;
function handleDirectMessage() {
setChatType("direct");
setActiveChat(node.num);
setActivePage("messages");
}
return (
<div className="dark:text-slate-900 p-1">
<div className="flex gap-2">
<div className="flex flex-col items-center gap-2 min-w-6 pt-1">
<Avatar text={node.user?.shortName ?? "UNK"} />
<div>
<Avatar text={shortName} />
<div onFocusCapture={(e) => {
// Required to prevent DM tooltip auto-appearing on creation
e.stopPropagation();
}}>
{node.user?.publicKey && node.user?.publicKey.length > 0
? (
<LockIcon
className="text-green-600"
className="text-green-600 mb-1.5"
size={12}
strokeWidth={3}
aria-label="Public Key Enabled"
@@ -48,19 +70,42 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
)
: (
<LockOpenIcon
className="text-yellow-500"
className="text-yellow-500 mb-1.5"
size={12}
strokeWidth={3}
aria-label="No Public Key"
/>
)}
</div>
<Star
fill={node.isFavorite ? "black" : "none"}
size={15}
aria-label={node.isFavorite ? "Favorite" : "Not a Favorite"}
/>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<MessageSquareIcon
size={14}
onClick={handleDirectMessage}
className="cursor-pointer hover:text-blue-500"
title="Send Message"
/>
</TooltipTrigger>
<TooltipPortal>
<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}
>
Direct Message {shortName}
</TooltipContent>
</TooltipPortal>
</Tooltip>
</TooltipProvider>
<Star
fill={node.isFavorite ? "black" : "none"}
size={15}
aria-label={node.isFavorite ? "Favorite" : "Not a Favorite"}
/>
</div>
</div>
<div>
@@ -70,7 +115,7 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
{!!node.deviceMetrics?.batteryLevel && (
<div
className="flex items-center gap-1"
className="flex items-center gap-1 mt-0.5"
title={`${node.deviceMetrics?.voltage?.toPrecision(3) ?? "Unknown"
} volts`}
>

View File

@@ -1,14 +1,10 @@
import { type MessageWithState, useDevice } from "@core/stores/deviceStore.ts";
import { Message } from "@components/PageComponents/Messages/Message.tsx";
import { MessageInput } from "@components/PageComponents/Messages/MessageInput.tsx";
import type { Types } from "@meshtastic/core";
import { InboxIcon } from "lucide-react";
import { useCallback, useEffect, useRef } from "react";
export interface ChannelChatProps {
messages?: MessageWithState[];
channel: Types.ChannelNumber;
to: Types.Destination;
}
const EmptyState = () => (
@@ -19,21 +15,22 @@ const EmptyState = () => (
);
export const ChannelChat = ({
messages,
channel,
to,
messages = [],
}: ChannelChatProps) => {
const { nodes } = useDevice();
const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const scrollToBottom = useCallback(() => {
const scrollContainer = scrollContainerRef.current;
if (scrollContainer) {
const isNearBottom = scrollContainer.scrollHeight -
scrollContainer.scrollTop -
scrollContainer.clientHeight <
const isNearBottom =
scrollContainer.scrollHeight -
scrollContainer.scrollTop -
scrollContainer.clientHeight <
100;
if (isNearBottom) {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}
@@ -42,7 +39,7 @@ export const ChannelChat = ({
useEffect(() => {
scrollToBottom();
}, [scrollToBottom]);
}, [scrollToBottom, messages]);
if (!messages?.length) {
return (
@@ -50,34 +47,31 @@ export const ChannelChat = ({
<div className="flex-1 flex items-center justify-center">
<EmptyState />
</div>
<div className="shrink-0 p-4 w-full dark:bg-slate-900">
<MessageInput to={to} channel={channel} maxBytes={200} />
</div>
</div>
);
}
return (
<div className="flex flex-col h-full container mx-auto">
<div className="flex-1 overflow-y-auto" ref={scrollContainerRef}>
<div className="w-full h-full flex flex-col justify-end pl-4 pr-44">
{messages.map((message, index) => {
return (
<Message
key={message.id}
message={message}
sender={nodes.get(message.from)}
lastMsgSameUser={index > 0 &&
messages[index - 1].from === message.from}
/>
);
})}
<div
ref={scrollContainerRef}
className="flex-1 overflow-y-auto pl-4 pr-4 md:pr-44"
>
<div className="flex flex-col justify-end min-h-full">
{messages?.map((message, index) => (
<Message
key={message.id}
message={message}
sender={nodes.get(message.from)}
lastMsgSameUser={
index > 0 && messages[index - 1].from === message.from
}
/>
))}
<div ref={messagesEndRef} className="w-full" />
</div>
</div>
<div className="shrink-0 mt-2 p-4 w-full dark:bg-slate-900">
<MessageInput to={to} channel={channel} maxBytes={200} />
</div>
</div>
);
};
};

View File

@@ -1,3 +1,4 @@
import { memo, useMemo } from "react";
import {
Tooltip,
TooltipArrow,
@@ -12,15 +13,13 @@ import {
import { cn } from "@core/utils/cn.ts";
import { Avatar } from "@components/UI/Avatar.tsx";
import type { Protobuf } from "@meshtastic/core";
import { AlertCircle, CheckCircle2, CircleEllipsis } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { useMemo } from "react";
import { AlertCircle, CheckCircle2, CircleEllipsis, LucideIcon } from "lucide-react";
const MESSAGE_STATES = {
ACK: "ack",
WAITING: "waiting",
FAILED: "failed",
} as const;
type MessageStateValue = {
state: string;
icon: LucideIcon;
displayText: string;
}
type MessageState = MessageWithState["state"];
@@ -40,31 +39,36 @@ interface StatusIconProps {
className?: string;
}
const STATUS_TEXT_MAP: Record<MessageState, string> = {
[MESSAGE_STATES.ACK]: "Message delivered",
[MESSAGE_STATES.WAITING]: "Waiting for delivery",
[MESSAGE_STATES.FAILED]: "Delivery failed",
const MESSAGE_STATES: Record<string, MessageStateValue> = {
ACK: { state: 'ack', icon: CheckCircle2, displayText: "Message delivered" },
WAITING: { state: 'waiting', icon: CircleEllipsis, displayText: "Waiting for delivery" },
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) => (
<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"
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"
align="center"
sideOffset={5}
>
{getStatusText(state)}
{getMessageState(state).displayText ?? "An unknown error occurred"};
<TooltipArrow className="fill-slate-800" />
</TooltipContent>
</Tooltip>
@@ -72,13 +76,17 @@ const StatusTooltip = ({ state, children }: StatusTooltipProps) => (
);
const StatusIcon = ({ state, className, ...otherProps }: StatusIconProps) => {
const isFailed = state === MESSAGE_STATES.FAILED;
const msgState = getMessageState(state);
const isFailed = msgState.state === 'failed'
const iconClass = cn(
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 (
<StatusTooltip state={state}>
<Icon
@@ -90,23 +98,7 @@ const StatusIcon = ({ state, className, ...otherProps }: StatusIconProps) => {
);
};
const getMessageTextStyles = (state: MessageState) => {
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 }) => (
const TimeDisplay = memo(({ 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()}
@@ -118,9 +110,9 @@ const TimeDisplay = ({
})}
</span>
</div>
);
));
export const Message = ({ lastMsgSameUser, message, sender }: MessageProps) => {
export const Message = memo(({ lastMsgSameUser, message, sender }: MessageProps) => {
const { getDevices } = useDeviceStore();
const isDeviceUser = useMemo(
@@ -128,33 +120,47 @@ export const Message = ({ lastMsgSameUser, message, sender }: MessageProps) => {
getDevices()
.map((device) => device.nodes.get(device.hardware.myNodeNum)?.num)
.includes(message.from),
[getDevices, message.from],
[getDevices, message.from]
);
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 (
<div className="flex flex-col w-full px-4 justify-start">
<div
className={cn(
"flex flex-col flex-wrap items-start py-1",
isDeviceUser && "items-end",
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?.shortName ?? "UNK"} />
<div className="flex flex-col">
<span className="font-medium text-slate-900 dark:text-white truncate">
{messageUser?.longName}
</span>
</div>
{!lastMsgSameUser && (
<div className="flex place-items-center gap-2 mb-1">
<Avatar text={messageUser?.shortName ?? "UNK"} />
<div className="flex flex-col">
<span className="font-medium text-slate-900 dark:text-white truncate">
{messageUser?.longName}
</span>
</div>
)
: null}
</div>
)}
</div>
<TimeDisplay date={message.rxTime} />
<div className="flex place-items-center gap-2 pb-2">
@@ -166,4 +172,4 @@ export const Message = ({ lastMsgSameUser, message, sender }: MessageProps) => {
</div>
</div>
);
};
});

View File

@@ -0,0 +1,152 @@
import { MessageInput } from '@components/PageComponents/Messages/MessageInput.tsx';
import { useDevice } from "@core/stores/deviceStore.ts";
import { vi, describe, it, expect, beforeEach, Mock } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
vi.mock("@core/stores/deviceStore.ts", () => ({
useDevice: vi.fn(),
}));
vi.mock("@core/utils/debounce.ts", () => ({
debounce: (fn: () => void) => fn,
}));
vi.mock("@components/UI/Button.tsx", () => ({
Button: ({ children, ...props }: { children: React.ReactNode }) => <button {...props}>{children}</button>
}));
vi.mock("@components/UI/Input.tsx", () => ({
Input: (props: any) => <input {...props} />
}));
vi.mock("lucide-react", () => ({
SendIcon: () => <div data-testid="send-icon">Send</div>
}));
// TODO: getting an error with this test
describe('MessageInput Component', () => {
const mockProps = {
to: "broadcast" as const,
channel: 0 as const,
maxBytes: 100,
};
const mockSetMessageDraft = vi.fn();
const mockSetMessageState = vi.fn();
const mockSendText = vi.fn().mockResolvedValue(123);
beforeEach(() => {
vi.clearAllMocks();
(useDevice as Mock).mockReturnValue({
connection: {
sendText: mockSendText,
},
setMessageState: mockSetMessageState,
messageDraft: "",
setMessageDraft: mockSetMessageDraft,
hardware: {
myNodeNum: 1234567890,
},
});
});
it('renders correctly with initial state', () => {
render(<MessageInput {...mockProps} />);
expect(screen.getByPlaceholderText('Enter Message')).toBeInTheDocument();
expect(screen.getByTestId('send-icon')).toBeInTheDocument();
expect(screen.getByText('0/100')).toBeInTheDocument();
});
it('updates local draft and byte count when typing', () => {
render(<MessageInput {...mockProps} />);
const inputField = screen.getByPlaceholderText('Enter Message');
fireEvent.change(inputField, { target: { value: 'Hello' } })
expect(screen.getByText('5/100')).toBeInTheDocument();
expect(inputField).toHaveValue('Hello');
expect(mockSetMessageDraft).toHaveBeenCalledWith('Hello');
});
it.skip('does not allow input exceeding max bytes', () => {
render(<MessageInput {...mockProps} maxBytes={5} />);
const inputField = screen.getByPlaceholderText('Enter Message');
expect(screen.getByText('0/100')).toBeInTheDocument();
userEvent.type(inputField, 'Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis p')
expect(screen.getByText('100/100')).toBeInTheDocument();
expect(inputField).toHaveValue('Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean m');
});
it.skip('sends message and resets form when submitting', async () => {
try {
render(<MessageInput {...mockProps} />);
const inputField = screen.getByPlaceholderText('Enter Message');
const submitButton = screen.getByText('Send');
fireEvent.change(inputField, { target: { value: 'Test Message' } });
fireEvent.click(submitButton);
const form = screen.getByRole('form');
fireEvent.submit(form);
expect(mockSendText).toHaveBeenCalledWith('Test message', 'broadcast', true, 0);
await waitFor(() => {
expect(mockSetMessageState).toHaveBeenCalledWith(
'broadcast',
0,
'broadcast',
1234567890,
123,
'ack'
);
});
expect(inputField).toHaveValue('');
expect(screen.getByText('0/100')).toBeInTheDocument();
expect(mockSetMessageDraft).toHaveBeenCalledWith('');
} catch (e) {
console.error(e);
}
});
it('prevents sending empty messages', () => {
render(<MessageInput {...mockProps} />);
const form = screen.getByPlaceholderText('Enter Message')
fireEvent.submit(form);
expect(mockSendText).not.toHaveBeenCalled();
});
it('initializes with existing message draft', () => {
(useDevice as Mock).mockReturnValue({
connection: {
sendText: mockSendText,
},
setMessageState: mockSetMessageState,
messageDraft: "Existing draft",
setMessageDraft: mockSetMessageDraft,
isQueueingMessages: false,
queueStatus: { free: 10 },
hardware: {
myNodeNum: 1234567890,
},
});
render(<MessageInput {...mockProps} />);
const inputField = screen.getByRole('textbox');
expect(inputField).toHaveValue('Existing draft');
});
});

View File

@@ -1,4 +1,4 @@
import { debounce } from "../../../core/utils/debounce.ts";
import { debounce } from "@core/utils/debounce.ts";
import { Button } from "@components/UI/Button.tsx";
import { Input } from "@components/UI/Input.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
@@ -22,6 +22,8 @@ export const MessageInput = ({
setMessageState,
messageDraft,
setMessageDraft,
isQueueingMessages,
queueStatus,
hardware,
} = useDevice();
const myNodeNum = hardware.myNodeNum;
@@ -33,8 +35,10 @@ export const MessageInput = ({
[setMessageDraft],
);
// sends the message to the selected destination
const sendText = useCallback(
async (message: string) => {
await connection
?.sendText(message, to, true, channel)
.then((id: number) =>
@@ -58,7 +62,7 @@ export const MessageInput = ({
)
);
},
[channel, connection, myNodeNum, setMessageState, to],
[channel, connection, myNodeNum, setMessageState, to, queueStatus],
);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -81,15 +85,18 @@ export const MessageInput = ({
if (localDraft === "") return;
const message = formData.get("messageInput") as string;
startTransition(() => {
sendText(message);
setLocalDraft("");
setMessageDraft("");
setMessageBytes(0);
if (!isQueueingMessages) {
sendText(message);
setLocalDraft("");
setMessageDraft("");
setMessageBytes(0);
}
});
}}
>
<div className="flex grow gap-2">
<span className="w-full">
<div className="flex grow gap-2 ">
<label className="w-full">
<Input
autoFocus
minLength={1}
@@ -98,12 +105,12 @@ export const MessageInput = ({
value={localDraft}
onChange={handleInputChange}
/>
</span>
<div className="flex items-center w-24 p-2 place-content-end">
</label>
<label data-testid="byte-counter" className="flex items-center w-24 p-2 place-content-end">
{messageBytes}/{maxBytes}
</div>
</label>
<Button type="submit">
<Button type="submit" className="dark:bg-white dark:text-slate-900 dark:hover:bg-slate-400 dark:hover:text-white">
<SendIcon size={16} />
</Button>
</div>

View File

@@ -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>
);
};

View File

@@ -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
});
});

View File

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

View File

@@ -10,6 +10,7 @@ export interface PageLayoutProps {
label: string;
noPadding?: boolean;
children: React.ReactNode;
className?: string;
actions?: {
icon: LucideIcon;
iconClasses?: string;
@@ -23,6 +24,7 @@ export const PageLayout = ({
label,
noPadding,
actions,
className,
children,
}: PageLayoutProps) => {
return (
@@ -63,6 +65,7 @@ export const PageLayout = ({
className={cn(
"flex h-full w-full flex-col overflow-y-auto",
!noPadding && "pl-3 pr-3 ",
className
)}
>
{children}

View File

@@ -3,6 +3,8 @@ import { SidebarButton } from "@components/UI/Sidebar/sidebarButton.tsx";
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import type { Page } from "@core/stores/deviceStore.ts";
import { Spinner } from "@components/UI/Spinner.tsx";
import {
BatteryMediumIcon,
CpuIcon,
@@ -58,7 +60,7 @@ export const Sidebar = ({ children }: SidebarProps) => {
page: "channels",
},
{
name: "Nodes",
name: `Nodes (${nodes.size - 1})`,
icon: UsersIcon,
page: "nodes",
},
@@ -66,47 +68,56 @@ export const Sidebar = ({ children }: SidebarProps) => {
return showSidebar
? (
<div className="min-w-[280px] max-w-min flex-col overflow-y-auto border-r-[0.5px] bg-background-primary border-slate-300 dark:border-slate-700">
<div className="flex justify-between px-8 pt-6">
<div>
<span className="text-lg font-medium">
{myNode?.user?.shortName ?? "UNK"}
</span>
<Subtle>{myNode?.user?.longName ?? "UNK"}</Subtle>
<div className="min-w-[280px] max-w-min flex-col overflow-y-auto border-r-[0.5px] bg-background-primary border-slate-300 dark:border-slate-400">
{myNode === undefined ? (
<div className="flex flex-col items-center justify-center px-8 py-6">
<Spinner />
<Subtle className="mt-2">Loading device info...</Subtle>
</div>
<button
type="button"
className="transition-all hover:text-accent"
onClick={() => setDialogOpen("deviceName", true)}
>
<EditIcon size={16} />
</button>
<button type="button" onClick={() => setShowSidebar(false)}>
<SidebarCloseIcon size={24} />
</button>
</div>
<div className="px-8 pb-6">
<div className="flex items-center">
<BatteryMediumIcon size={24} viewBox="0 0 28 24" />
<Subtle>
{myNode?.deviceMetrics?.batteryLevel
? myNode?.deviceMetrics?.batteryLevel > 100
? "Charging"
: `${myNode?.deviceMetrics?.batteryLevel}%`
: "UNK"}
</Subtle>
</div>
<div className="flex items-center">
<ZapIcon size={24} viewBox="0 0 36 24" />
<Subtle>
{myNode?.deviceMetrics?.voltage?.toPrecision(3) ?? "UNK"} volts
</Subtle>
</div>
<div className="flex items-center">
<CpuIcon size={24} viewBox="0 0 36 24" />
<Subtle>v{myMetadata?.firmwareVersion ?? "UNK"}</Subtle>
</div>
</div>
) : (
<>
<div className="flex justify-between px-8 pt-6">
<div>
<span className="text-lg font-medium">
{myNode.user?.shortName ?? "UNK"}
</span>
<Subtle>{myNode.user?.longName ?? "UNK"}</Subtle>
</div>
<button
type="button"
className="transition-all hover:text-accent"
onClick={() => setDialogOpen("deviceName", true)}
>
<EditIcon size={16} />
</button>
<button type="button" onClick={() => setShowSidebar(false)}>
<SidebarCloseIcon size={24} />
</button>
</div>
<div className="px-8 pb-6">
<div className="flex items-center">
<BatteryMediumIcon size={24} viewBox="0 0 28 24" />
<Subtle>
{myNode.deviceMetrics?.batteryLevel
? myNode.deviceMetrics.batteryLevel > 100
? "Charging"
: `${myNode.deviceMetrics.batteryLevel}%`
: "UNK"}
</Subtle>
</div>
<div className="flex items-center">
<ZapIcon size={24} viewBox="0 0 36 24" />
<Subtle>
{myNode.deviceMetrics?.voltage?.toPrecision(3) ?? "UNK"} volts
</Subtle>
</div>
<div className="flex items-center">
<CpuIcon size={24} viewBox="0 0 36 24" />
<Subtle>v{myMetadata?.firmwareVersion ?? "UNK"}</Subtle>
</div>
</div>
</>
)}
<SidebarSection label="Navigation">
{pages.map((link) => (
@@ -115,9 +126,12 @@ export const Sidebar = ({ children }: SidebarProps) => {
label={link.name}
Icon={link.icon}
onClick={() => {
setActivePage(link.page);
if (myNode !== undefined) {
setActivePage(link.page);
}
}}
active={link.page === activePage}
disabled={myNode === undefined}
/>
))}
</SidebarSection>

View File

@@ -24,18 +24,25 @@ export default function ThemeSwitcher({
setPreference(nextPreference);
};
const [firstCharOfPreference = "", ...restOfPreference] = preference;
return (
<button
type="button"
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,
)}
onClick={toggleTheme}
aria-label={preference === "system"
? `System theme (currently ${theme}). Click to change theme.`
: `Current theme: ${theme}. Click to change theme.`}
aria-description={"Change current 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]}
</button>
);

View File

@@ -5,8 +5,8 @@ import {
ToastProvider,
ToastTitle,
ToastViewport,
} from "./UI/Toast.tsx";
import { useToast } from "../core/hooks/useToast.ts";
} from "@components/UI/Toast.tsx";
import { useToast } from "@core/hooks/useToast.ts";
export function Toaster() {
const { toasts } = useToast();

View File

@@ -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";
type RGBColor = {
@@ -12,6 +13,7 @@ interface AvatarProps {
text: string;
size?: "sm" | "lg";
className?: string;
showError?: boolean;
}
// biome-ignore lint/complexity/noStaticOnlyClass: stop being annoying Biome
@@ -43,6 +45,7 @@ class ColorUtils {
export const Avatar: React.FC<AvatarProps> = ({
text,
size = "sm",
showError = false,
className,
}) => {
const sizes = {
@@ -73,12 +76,11 @@ export const Avatar: React.FC<AvatarProps> = ({
return (
<div
className={cn(
`
`flex
relative
rounded-full
flex
items-center
justify-center
size-11
font-semibold`,
sizes[size],
className,
@@ -88,6 +90,7 @@ export const Avatar: React.FC<AvatarProps> = ({
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>
</div>
);

View File

@@ -15,7 +15,7 @@ const buttonVariants = cva(
success:
"bg-green-500 text-white hover:bg-green-600 dark:hover:bg-green-600",
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:
"bg-slate-100 text-slate-700 hover:bg-slate-200 dark:bg-slate-500 dark:text-white dark:hover:bg-slate-400",
ghost:
@@ -40,16 +40,20 @@ export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
export interface ButtonProps
extends
React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> { }
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => {
({ className, variant, size, disabled, ...props }, ref) => {
return (
<button
type="button"
className={cn(buttonVariants({ variant, size, className }))}
className={cn(
buttonVariants({ variant, size, className }),
{ "cursor-not-allowed": disabled }
)}
ref={ref}
disabled={disabled}
{...props}
/>
);

View File

@@ -1,28 +0,0 @@
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react";
import * as React from "react";
import { cn } from "@core/utils/cn.ts";
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-xs border border-slate-300 focus:outline-hidden focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:text-slate-50 dark:focus:ring-slate-400 dark:focus:ring-offset-slate-900",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };

View File

@@ -0,0 +1,120 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
import { Checkbox } from '@components/UI/Checkbox/index.tsx';
import React from "react";
vi.mock('@components/UI/Label.tsx', () => ({
Label: ({ children, className, htmlFor, id }: { children: React.ReactNode; className: string; htmlFor: string; id: string }) => (
<label data-testid="label-component" className={className} htmlFor={htmlFor} id={id}>
{children}
</label>
),
}));
vi.mock('@core/utils/cn.ts', () => ({
cn: (...args: any) => args.filter(Boolean).join(' '),
}));
vi.mock('react', async () => {
const actual = await vi.importActual('react');
return {
...actual,
useId: () => 'test-id',
};
});
describe('Checkbox', () => {
beforeEach(cleanup);
it('renders unchecked by default', () => {
render(<Checkbox />);
const checkbox = screen.getByRole('checkbox');
expect(checkbox).not.toBeChecked();
expect(screen.queryByText('Check')).not.toBeInTheDocument();
});
it('renders checked when checked prop is true', () => {
render(<Checkbox checked={true} />);
expect(screen.getByRole('checkbox')).toBeChecked();
expect(screen.getByRole('presentation')).toBeInTheDocument();
});
it('calls onChange when clicked', () => {
const onChange = vi.fn();
render(<Checkbox onChange={onChange} />);
fireEvent.click(screen.getByRole('presentation'));
expect(onChange).toHaveBeenCalledWith(true);
fireEvent.click(screen.getByRole('presentation'));
expect(onChange).toHaveBeenCalledWith(false);
});
it('uses provided id', () => {
render(<Checkbox id="custom-id" />);
expect(screen.getByRole('checkbox').id).toBe('custom-id');
});
it('generates id when not provided', () => {
render(<Checkbox />);
expect(screen.getByRole('checkbox').id).toBe('test-id');
});
it('renders children in Label component', () => {
render(<Checkbox>Test Label</Checkbox>);
expect(screen.getByTestId('label-component')).toHaveTextContent('Test Label');
});
it('applies custom className', () => {
const { container } = render(<Checkbox className="custom-class" />);
expect(container.firstChild).toHaveClass('custom-class');
});
it('applies labelClassName to Label', () => {
render(<Checkbox labelClassName="label-class">Test</Checkbox>);
expect(screen.getByTestId('label-component')).toHaveClass('label-class');
});
it('disables checkbox when disabled prop is true', () => {
render(<Checkbox disabled />);
expect(screen.getByRole('checkbox')).toBeDisabled();
expect(screen.getByRole('presentation')).toHaveClass('opacity-50');
});
it('does not call onChange when disabled', () => {
const onChange = vi.fn();
render(<Checkbox onChange={onChange} disabled />);
fireEvent.click(screen.getByRole('presentation'));
expect(onChange).not.toHaveBeenCalled();
});
it('sets required attribute when required prop is true', () => {
render(<Checkbox required />);
expect(screen.getByRole('checkbox')).toHaveAttribute('required');
});
it('sets name attribute when name prop is provided', () => {
render(<Checkbox name="test-name" />);
expect(screen.getByRole('checkbox')).toHaveAttribute('name', 'test-name');
});
it('passes through additional props', () => {
render(<Checkbox data-testid="extra-prop" />);
expect(screen.getByRole('checkbox')).toHaveAttribute('data-testid', 'extra-prop');
});
it('toggles checked state correctly', () => {
render(<Checkbox />);
const checkbox = screen.getByRole('checkbox');
const presentation = screen.getByRole('presentation');
expect(checkbox).not.toBeChecked();
fireEvent.click(presentation);
expect(checkbox).toBeChecked();
fireEvent.click(presentation);
expect(checkbox).not.toBeChecked();
});
});

View File

@@ -0,0 +1,93 @@
import { useState, useId } from "react";
import { Check } from "lucide-react";
import { Label } from "@components/UI/Label.tsx";
import { cn } from "@core/utils/cn.ts";
interface CheckboxProps {
checked?: boolean;
onChange?: (checked: boolean) => void;
className?: string;
labelClassName?: string;
id?: string;
children?: React.ReactNode;
disabled?: boolean;
required?: boolean;
name?: string;
}
export function Checkbox({
checked,
onChange,
className,
labelClassName,
id: propId,
children,
disabled = false,
required = false,
name,
...rest
}: CheckboxProps) {
const generatedId = useId();
const id = propId || generatedId;
const [isChecked, setIsChecked] = useState(checked || false);
const handleToggle = () => {
if (disabled) return;
const newChecked = !isChecked;
setIsChecked(newChecked);
onChange?.(newChecked);
};
return (
<div className={cn("flex items-center", className)}>
<div className="relative flex items-start">
<div className="flex items-center h-5">
<input
type="checkbox"
id={id}
checked={isChecked}
onChange={handleToggle}
disabled={disabled}
required={required}
name={name}
className="sr-only"
{...rest}
/>
<div
onClick={handleToggle}
role="presentation"
className={cn(
"w-6 h-6 border-2 border-gray-500 rounded-md flex items-center justify-center",
disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2",
isChecked ? "" : ""
)}
>
{isChecked && (
<div className="animate-fade-in scale-100 opacity-100">
<Check className="w-4 h-4 text-slate-900 dark:text-slate-900" />
</div>
)}
</div>
</div>
{children && (
<div className="ml-3 text-sm">
<Label
htmlFor={id}
id={`${id}-label`}
className={cn(
"text-gray-900 dark:text-gray-900",
disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer",
labelClassName
)}
>
{children}
</Label>
</div>
)}
</div>
</div>
);
}

View File

@@ -116,7 +116,7 @@ const CommandItem = React.forwardRef<
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-md py-1.5 px-2 text-sm font-medium outline-hidden aria-selected:bg-slate-100 data-[disabled='true']:pointer-events-none data-[disabled='true']:opacity-50 dark:aria-selected:bg-slate-700",
"relative flex cursor-default select-none items-center rounded-md py-1.5 px-2 text-sm font-medium outline-hidden aria-selected:bg-slate-100 data-[disabled='true']:pointer-events-none data-[disabled='true']:opacity-50 dark:aria-selected:bg-slate-700 dark:text-white ",
className,
)}
{...props}

View File

@@ -50,15 +50,28 @@ const DialogContent = React.forwardRef<
{...props}
>
{children}
<DialogPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-slate-100 dark:focus:ring-slate-400 dark:focus:ring-offset-slate-900">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogClose = ({
className,
...props
}: DialogPrimitive.DialogCloseProps & React.RefAttributes<HTMLButtonElement> & { className?: string }) => (
<DialogPrimitive.Close
name="close"
className={cn(
"absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-slate-100 dark:focus:ring-slate-400 dark:focus:ring-offset-slate-900",
className,
)}
{...props}
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
);
const DialogHeader = ({
className,
...props
@@ -119,4 +132,5 @@ export {
DialogHeader,
DialogTitle,
DialogTrigger,
DialogClose,
};

View File

@@ -1,8 +1,8 @@
import newGithubIssueUrl from "../../core/utils/github.ts";
import newGithubIssueUrl from "@core/utils/github.ts";
import { ExternalLink } from "lucide-react";
import { Heading } from "./Typography/Heading.tsx";
import { Link } from "./Typography/Link.tsx";
import { P } from "./Typography/P.tsx";
import { Heading } from "@components/UI/Typography/Heading.tsx";
import { Link } from "@components/UI/Typography/Link.tsx";
import { P } from "@components/UI/Typography/P.tsx";
export function ErrorPage({ error }: { error: Error }) {
@@ -11,8 +11,8 @@ export function ErrorPage({ error }: { error: Error }) {
}
return (
<article className="w-full overflow-y-auto">
<section className="flex shrink md:flex-row gap-16 mt-20 px-4 md:px-8 text-lg md:text-xl space-y-2 place-items-center">
<article className="w-full h-screen overflow-y-auto dark:bg-background-primary dark:text-text-primary">
<section className="flex shrink md:flex-row gap-16 mt-20 px-4 md:px-8 text-lg md:text-xl space-y-2 place-items-center dark:bg-background-primary text-slate-900 dark:text-text-primary">
<div>
<Heading as="h2" className="text-text-primary">
This is a little embarrassing...

View File

@@ -21,8 +21,8 @@ const inputVariants = cva(
export interface InputProps
extends
React.InputHTMLAttributes<HTMLInputElement>,
VariantProps<typeof inputVariants> {
React.InputHTMLAttributes<HTMLInputElement>,
VariantProps<typeof inputVariants> {
prefix?: string;
suffix?: string;
action?: {
@@ -36,9 +36,9 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
return (
<div className="relative w-full">
{prefix && (
<span className="inline-flex items-center rounded-l-md bg-slate-100/80 px-3 font-mono text-sm text-slate-600">
<label className="inline-flex items-center rounded-l-md bg-slate-100/80 px-3 font-mono text-sm text-slate-600">
{prefix}
</span>
</label>
)}
<input
className={cn(

View File

@@ -7,6 +7,7 @@ export interface SidebarButtonProps {
Icon?: LucideIcon;
element?;
onClick?: () => void;
disabled?: boolean;
}
export const SidebarButton = ({
@@ -15,12 +16,14 @@ export const SidebarButton = ({
Icon,
element,
onClick,
disabled = false,
}: SidebarButtonProps) => (
<Button
onClick={onClick}
variant={active ? "subtle" : "ghost"}
size="sm"
className="flex gap-2 w-full"
disabled={disabled}
>
{Icon && <Icon size={16} />}
{element && element}

View File

@@ -12,7 +12,7 @@ export const Link = ({ href, children, className }: LinkProps) => (
target="_blank"
rel="noopener noreferrer"
className={cn(
"font-medium text-slate-900 underline underline-offset-4 dark:text-slate-50",
"font-medium text-slate-900 underline underline-offset-4 dark:text-slate-900",
className,
)}
>

View File

@@ -0,0 +1,111 @@
import { describe, it, expect } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { Table } from "@components/generic/Table/index.tsx";
import { TimeAgo } from "@components/generic/TimeAgo.tsx";
import { Mono } from "@components/generic/Mono.tsx";
describe("Generic Table", () => {
it("Can render an empty table.", () => {
render(
<Table
headings={[]}
rows={[]}
/>
);
expect(screen.getByRole("table")).toBeInTheDocument();
});
it("Can render a table with headers and no rows.", async () => {
render(
<Table
headings={[
{ title: "", type: "blank", sortable: false },
{ title: "Short Name", type: "normal", sortable: true },
{ title: "Long Name", type: "normal", sortable: true },
{ title: "Model", type: "normal", sortable: true },
{ title: "MAC Address", type: "normal", sortable: true },
{ title: "Last Heard", type: "normal", sortable: true },
{ title: "SNR", type: "normal", sortable: true },
{ title: "Encryption", type: "normal", sortable: false },
{ title: "Connection", type: "normal", sortable: true },
]}
rows={[]}
/>
);
await screen.findByRole('table');
expect(screen.getAllByRole("columnheader")).toHaveLength(9);
});
// A simplified version of the rows in pages/Nodes.tsx for testing purposes
const mockDevicesWithShortNameAndConnection = [
{user: {shortName: "TST1"}, hopsAway: 1, lastHeard: Date.now() + 1000 },
{user: {shortName: "TST2"}, hopsAway: 0, lastHeard: Date.now() + 4000 },
{user: {shortName: "TST3"}, hopsAway: 4, lastHeard: Date.now() },
{user: {shortName: "TST4"}, hopsAway: 3, lastHeard: Date.now() + 2000 }
];
const mockRows = mockDevicesWithShortNameAndConnection.map(node => [
<h1 data-testshortname> { node.user.shortName } </h1>,
<><TimeAgo timestamp={node.lastHeard * 1000} /></>,
<Mono key="hops" data-testhops>
{node.lastHeard !== 0
? node.hopsAway === 0
? "Direct"
: `${node.hopsAway?.toString()} ${
node.hopsAway > 1 ? "hops" : "hop"
} away`
: "-"}
</Mono>
])
it("Can sort rows appropriately.", async () => {
render(
<Table
headings={[
{ title: "Short Name", type: "normal", sortable: true },
{ title: "Last Heard", type: "normal", sortable: true },
{ title: "Connection", type: "normal", sortable: true },
]}
rows={mockRows}
/>
);
const renderedTable = await screen.findByRole('table');
const columnHeaders = screen.getAllByRole("columnheader");
expect(columnHeaders).toHaveLength(3);
// Will be sorted "Last heard" "asc" by default
expect( [...renderedTable.querySelectorAll('[data-testshortname]')]
.map(el=>el.textContent)
.map(v=>v?.trim())
.join(','))
.toMatch('TST2,TST4,TST1,TST3');
fireEvent.click(columnHeaders[0]);
// Re-sort by Short Name asc
expect( [...renderedTable.querySelectorAll('[data-testshortname]')]
.map(el=>el.textContent)
.map(v=>v?.trim())
.join(','))
.toMatch('TST1,TST2,TST3,TST4');
fireEvent.click(columnHeaders[0]);
// Re-sort by Short Name desc
expect( [...renderedTable.querySelectorAll('[data-testshortname]')]
.map(el=>el.textContent)
.map(v=>v?.trim())
.join(','))
.toMatch('TST4,TST3,TST2,TST1');
fireEvent.click(columnHeaders[2]);
// Re-sort by Hops Away
expect( [...renderedTable.querySelectorAll('[data-testshortname]')]
.map(el=>el.textContent)
.map(v=>v?.trim())
.join(','))
.toMatch('TST2,TST1,TST4,TST3');
});
})

View File

@@ -12,6 +12,20 @@ export interface Heading {
sortable: boolean;
}
/**
* @param hopsAway String describing the number of hops away the node is from the current node
* @returns number of hopsAway or `0` if hopsAway is 'Direct'
*/
function numericHops(hopsAway: string): number {
if(hopsAway.match(/direct/i)){
return 0;
}
if ( hopsAway.match(/\d+\s+hop/gi) ) {
return Number( hopsAway.match(/(\d+)\s+hop/i)?.[1] );
}
return Number.MAX_SAFE_INTEGER;
}
export const Table = ({ headings, rows }: TableProps) => {
const [sortColumn, setSortColumn] = useState<string | null>("Last Heard");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
@@ -46,6 +60,20 @@ export const Table = ({ headings, rows }: TableProps) => {
return 0;
}
// Custom comparison for 'Connection' column
if (sortColumn === "Connection") {
const aNumHops = numericHops(aValue instanceof Array ? aValue[0] : aValue);
const bNumHops = numericHops(bValue instanceof Array ? bValue[0] : bValue);
if (aNumHops < bNumHops) {
return sortOrder === "asc" ? -1 : 1;
}
if (aNumHops > bNumHops) {
return sortOrder === "asc" ? 1 : -1;
}
return 0;
}
// Default comparison for other columns
if (aValue < bValue) {
return sortOrder === "asc" ? -1 : 1;
@@ -64,7 +92,7 @@ export const Table = ({ headings, rows }: TableProps) => {
<th
key={heading.title}
scope="col"
className={`py-2 pr-3 pl-6 text-left ${
className={`py-2 pr-3 text-left ${
heading.sortable
? "cursor-pointer hover:brightness-hover active:brightness-press"
: ""
@@ -86,8 +114,16 @@ export const Table = ({ headings, rows }: TableProps) => {
<tbody>
{sortedRows.map((row, index) => (
// 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) => (
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
key={item.key ?? index}
className="whitespace-nowrap py-2 text-sm text-text-secondary first:pl-2"
@@ -100,4 +136,4 @@ export const Table = ({ headings, rows }: TableProps) => {
</tbody>
</table>
);
};
};

View File

@@ -1,71 +1,58 @@
import { Button } from "../../components/UI/Button.tsx";
import type { CookieAttributes } from "js-cookie";
import { Button } from "@components/UI/Button.tsx";
import { useCallback, useEffect, useRef } from "react";
import useCookie from "./useCookie.ts";
import { useToast } from "./useToast.ts";
import { useToast } from "@core/hooks/useToast.ts";
import useLocalStorage from "@core/hooks/useLocalStorage.ts";
interface UseBackupReminderOptions {
reminderInDays?: number;
message: string;
onAccept?: () => void | Promise<void>;
enabled: boolean;
cookieOptions?: CookieAttributes;
}
interface ReminderState {
suppressed: boolean;
lastShown: string;
expires: string;
}
const TOAST_APPEAR_DELAY = 10_000; // 10 seconds;
const TOAST_DURATION = 30_000; // 30 seconds;:
const TOAST_APPEAR_DELAY = 10_000; // 10 seconds
const TOAST_DURATION = 30_000; // 30 seconds
const REMINDER_DAYS_ONE_WEEK = 7;
const REMINDER_DAYS_ONE_YEAR = 365;
const REMINDER_DAYS_FOREVER = 3650;
const STORAGE_KEY = "key_backup_reminder";
// remind user in 1 year to backup keys again, if they accept the reminder;
const ON_ACCEPT_REMINDER_DAYS = 365;
function isReminderExpired(expires?: string): boolean {
if (!expires) return true;
const expiryDate = new Date(expires);
if (isNaN(expiryDate.getTime())) return true; // Invalid date passed
function isReminderExpired(lastShown: string): boolean {
const lastShownDate = new Date(lastShown);
const now = new Date();
const daysSinceLastShown = (now.getTime() - lastShownDate.getTime()) /
(1000 * 60 * 60 * 24);
return daysSinceLastShown >= 7;
return now.getTime() >= expiryDate.getTime();
}
export function useBackupReminder({
reminderInDays = 7,
enabled,
message,
onAccept = () => {},
cookieOptions,
onAccept = () => { },
reminderInDays = REMINDER_DAYS_ONE_WEEK,
}: UseBackupReminderOptions) {
const { toast } = useToast();
const toastShownRef = useRef(false);
const { value: reminderCookie, setCookie } = useCookie<ReminderState>(
"key_backup_reminder",
const [reminderState, setReminderState] = useLocalStorage<ReminderState | null>(
STORAGE_KEY,
null
);
const suppressReminder = useCallback(
(days: number) => {
const expiryDate = new Date();
expiryDate.setDate(expiryDate.getDate() + days);
setCookie(
{
suppressed: true,
lastShown: new Date().toISOString(),
},
{ ...cookieOptions, expires: expiryDate },
);
},
[setCookie, cookieOptions],
);
const setReminderExpiry = useCallback((days: number) => {
const expiryDate = new Date();
expiryDate.setDate(expiryDate.getDate() + days);
setReminderState({ expires: expiryDate.toISOString() });
}, [setReminderState]);
useEffect(() => {
if (!enabled || toastShownRef.current) return;
const shouldShowReminder = !reminderCookie?.suppressed ||
isReminderExpired(reminderCookie.lastShown);
if (!shouldShowReminder) return;
if (!isReminderExpired(reminderState?.expires)) return;
toastShownRef.current = true;
@@ -75,44 +62,52 @@ export function useBackupReminder({
delay: TOAST_APPEAR_DELAY,
description: message,
action: (
<div className="flex gap-2">
<div className="flex flex-col gap-2">
<div className="flex gap-2">
<Button
type="button"
variant="outline"
className="p-1"
onClick={() => {
dismiss();
setReminderExpiry(reminderInDays);
}}
>
Remind me in {reminderInDays} day{reminderInDays > 1 ? 's' : ''}
</Button>
<Button
type="button"
variant="outline"
className="p-1"
onClick={() => {
dismiss();
setReminderExpiry(REMINDER_DAYS_FOREVER);
}}
>
Never remind me
</Button>
</div>
<Button
type="button"
variant="default"
className="w-full"
onClick={() => {
onAccept();
dismiss();
suppressReminder(ON_ACCEPT_REMINDER_DAYS);
setReminderExpiry(REMINDER_DAYS_ONE_YEAR);
}}
>
Back up now
</Button>
<Button
type="button"
variant="outline"
onClick={() => {
dismiss();
suppressReminder(reminderInDays);
}}
>
Remind me in {reminderInDays} days
</Button>
</div>
),
});
return () => {
if (!toastShownRef.current) {
dismiss();
}
};
return () => dismiss();
}, [
enabled,
message,
onAccept,
reminderInDays,
suppressReminder,
toast,
reminderCookie,
]);
}
};

View File

@@ -0,0 +1,52 @@
import { renderHook, act } from '@testing-library/react'
import useLocalStorage from './useLocalStorage'
import { beforeEach, describe, expect, it } from "vitest";
describe('useLocalStorage', () => {
const key = 'test-key'
beforeEach(() => {
localStorage.clear()
})
it('should initialize with initial value if localStorage is empty', () => {
const { result } = renderHook(() => useLocalStorage(key, 'initial'))
const [value] = result.current
expect(value).toBe('initial')
})
it('should read existing value from localStorage', () => {
localStorage.setItem(key, JSON.stringify('stored'))
const { result } = renderHook(() => useLocalStorage(key, 'initial'))
const [value] = result.current
expect(value).toBe('stored')
})
it('should update localStorage when setValue is called', () => {
const { result } = renderHook(() => useLocalStorage(key, 'initial'))
const [, setValue] = result.current
act(() => {
setValue('updated')
})
expect(localStorage.getItem(key)).toBe(JSON.stringify('updated'))
expect(result.current[0]).toBe('updated')
})
it('should remove value from localStorage when removeValue is called', () => {
const { result } = renderHook(() => useLocalStorage(key, 'initial'))
const [, setValue, removeValue] = result.current
act(() => {
setValue('to-be-removed')
})
act(() => {
removeValue()
})
expect(localStorage.getItem(key)).toBeNull()
expect(result.current[0]).toBe('initial')
})
})

View File

@@ -0,0 +1,179 @@
// taken from https://react-hooked.vercel.app/docs/useLocalStorage/
import { useCallback, useEffect, useState } from "react";
import type { Dispatch, SetStateAction } from "react";
declare global {
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
interface WindowEventMap {
"local-storage": CustomEvent;
}
}
type UseLocalStorageOptions<T> = {
serializer?: (value: T) => string;
deserializer?: (value: string) => T;
initializeWithValue?: boolean;
};
const IS_SERVER = typeof window === "undefined";
/**
* Hook for persisting state to localStorage.
*
* @param {string} key - The key to use for localStorage.
* @param {T | (() => T)} initialValue - The initial value to use, if not found in localStorage.
* @param {UseLocalStorageOptions<T>} options - Options for the hook.
* @returns A tuple of [storedValue, setValue, removeValue].
*/
export default function useLocalStorage<T>(
key: string,
initialValue: T | (() => T),
options: UseLocalStorageOptions<T> = {},
): [T, Dispatch<SetStateAction<T>>, () => void] {
const { initializeWithValue = true } = options;
const serializer = useCallback<(value: T) => string>(
(value) => {
if (options.serializer) {
return options.serializer(value);
}
return JSON.stringify(value);
},
[options],
);
const deserializer = useCallback<(value: string) => T>(
(value) => {
if (options.deserializer) {
return options.deserializer(value);
}
// Support 'undefined' as a value
if (value === "undefined") {
return undefined as unknown as T;
}
const defaultValue =
initialValue instanceof Function ? initialValue() : initialValue;
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch (error) {
console.error("Error parsing JSON:", error);
return defaultValue; // Return initialValue if parsing fails
}
return parsed as T;
},
[options, initialValue],
);
// Get from local storage then
// parse stored json or return initialValue
const readValue = useCallback((): T => {
const initialValueToUse =
initialValue instanceof Function ? initialValue() : initialValue;
// Prevent build error "window is undefined" but keep working
if (IS_SERVER) {
return initialValueToUse;
}
try {
const raw = window.localStorage.getItem(key);
return raw ? deserializer(raw) : initialValueToUse;
} catch (error) {
console.warn(`Error reading localStorage key “${key}”:`, error);
return initialValueToUse;
}
}, [initialValue, key, deserializer]);
const [storedValue, setStoredValue] = useState(() => {
if (initializeWithValue) {
return readValue();
}
return initialValue instanceof Function ? initialValue() : initialValue;
});
// Return a wrapped version of useState's setter function that ...
// ... persists the new value to localStorage.
const setValue: Dispatch<SetStateAction<T>> = useCallback(
(value) => {
// Prevent build error "window is undefined" but keeps working
if (IS_SERVER) {
console.warn(
`Tried setting localStorage key “${key}” even though environment is not a client`,
);
}
try {
// Allow value to be a function so we have the same API as useState
const newValue = value instanceof Function ? value(readValue()) : value;
// Save to local storage
window.localStorage.setItem(key, serializer(newValue));
// Save state
setStoredValue(newValue);
// We dispatch a custom event so every similar useLocalStorage hook is notified
window.dispatchEvent(new StorageEvent("local-storage", { key }));
} catch (error) {
console.warn(`Error setting localStorage key “${key}”:`, error);
}
},
[key, serializer, readValue],
);
const removeValue = useCallback(() => {
// Prevent build error "window is undefined" but keeps working
if (IS_SERVER) {
console.warn(
`Tried removing localStorage key “${key}” even though environment is not a client`,
);
}
const defaultValue =
initialValue instanceof Function ? initialValue() : initialValue;
// Remove the key from local storage
window.localStorage.removeItem(key);
// Save state with default value
setStoredValue(defaultValue);
// We dispatch a custom event so every similar useLocalStorage hook is notified
window.dispatchEvent(new StorageEvent("local-storage", { key }));
}, [key]);
useEffect(() => {
setStoredValue(readValue());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key]);
const handleStorageChange = useCallback(
(event: StorageEvent | CustomEvent) => {
if ((event as StorageEvent).key && (event as StorageEvent).key !== key) {
return;
}
setStoredValue(readValue());
},
[key, readValue],
);
useEffect(() => {
addEventListener("storage", handleStorageChange);
// this is a custom event, triggered in writeValueToLocalStorage
addEventListener("local-storage", handleStorageChange);
return () => {
removeEventListener("storage", handleStorageChange);
removeEventListener("local-storage", handleStorageChange);
};
}, []);
return [storedValue, setValue, removeValue];
}

View File

@@ -0,0 +1,65 @@
import { renderHook, act } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { usePinnedItems } from "./usePinnedItems.ts";
const mockSetPinnedItems = vi.fn();
const mockUseLocalStorage = vi.fn();
vi.mock("@core/hooks/useLocalStorage.ts", () => ({
default: (...args: any[]) => mockUseLocalStorage(...args),
}));
describe("usePinnedItems", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns default pinnedItems and togglePinnedItem", () => {
mockUseLocalStorage.mockReturnValue([[], mockSetPinnedItems]);
const { result } = renderHook(() =>
usePinnedItems({ storageName: "test-storage" })
);
expect(result.current.pinnedItems).toEqual([]);
expect(typeof result.current.togglePinnedItem).toBe("function");
});
it("adds an item if it's not already pinned", () => {
mockUseLocalStorage.mockReturnValue([["item1"], mockSetPinnedItems]);
const { result } = renderHook(() =>
usePinnedItems({ storageName: "test-storage" })
);
act(() => {
result.current.togglePinnedItem("item2");
});
expect(mockSetPinnedItems).toHaveBeenCalledWith(expect.any(Function));
const updater = mockSetPinnedItems.mock.calls[0][0];
const updated = updater(["item1"]);
expect(updated).toEqual(["item1", "item2"]);
});
it("removes an item if it's already pinned", () => {
mockUseLocalStorage.mockReturnValue([["item1", "item2"], mockSetPinnedItems]);
const { result } = renderHook(() =>
usePinnedItems({ storageName: "test-storage" })
);
act(() => {
result.current.togglePinnedItem("item1");
});
expect(mockSetPinnedItems).toHaveBeenCalledWith(expect.any(Function));
const updater = mockSetPinnedItems.mock.calls[0][0];
const updated = updater(["item1", "item2"]);
expect(updated).toEqual(["item2"]);
});
});

View File

@@ -0,0 +1,19 @@
import useLocalStorage from "@core/hooks/useLocalStorage.ts";
import { useCallback } from "react";
export function usePinnedItems({ storageName }: { storageName: string }) {
const [pinnedItems, setPinnedItems] = useLocalStorage<string[]>(storageName, []);
const togglePinnedItem = useCallback((label: string) => {
setPinnedItems((prev) =>
prev.includes(label)
? prev.filter((g) => g !== label)
: [...prev, label]
);
}, []);
return {
pinnedItems,
togglePinnedItem,
};
}

View File

@@ -0,0 +1,81 @@
import { renderHook, act } from '@testing-library/react'
import { useToast } from "@core/hooks/useToast.ts"
import { Button } from '@components/UI/Button.tsx'
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
describe('useToast', () => {
beforeEach(() => {
// Reset toast memory state before each test
// our hook uses global memory to store toasts
// @ts-expect-error - internal test reset
globalThis.memoryState = { toasts: [] }
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('should create a toast with title, description, and action', () => {
const { result } = renderHook(() => useToast())
act(() => {
result.current.toast({
title: 'Backup Reminder',
description: 'Don\'t forget to backup!',
action: <Button>Backup Now</Button>
})
vi.runAllTimers()
})
const toast = result.current.toasts[0]
expect(result.current.toasts.length).toBe(1)
expect(toast.title).toBe('Backup Reminder')
expect(toast.description).toBe('Don\'t forget to backup!')
expect(toast.action).toBeTruthy()
expect(toast.open).toBe(true)
})
it('should dismiss a toast using returned dismiss function', () => {
const { result } = renderHook(() => useToast())
vi.useFakeTimers()
let toastRef: { id: string, dismiss: () => void }
act(() => {
toastRef = result.current.toast({ title: 'Dismiss Me' })
vi.runAllTimers() // Flush ADD_TOAST
})
act(() => {
toastRef.dismiss()
})
const toast = result.current.toasts.find(t => t.id === toastRef.id)
expect(toast?.open).toBe(false)
vi.useRealTimers()
})
it('should allow dismiss via hook dismiss function', () => {
const { result } = renderHook(() => useToast())
vi.useFakeTimers()
let toastRef: { id: string }
act(() => {
toastRef = result.current.toast({ title: 'Manual Dismiss' })
vi.runAllTimers()
})
act(() => {
result.current.dismiss(toastRef.id)
})
const toast = result.current.toasts.find(t => t.id === toastRef.id)
expect(toast?.open).toBe(false)
vi.useRealTimers()
})
})

View File

@@ -155,7 +155,7 @@ function toast({ delay = 0, ...props }: Toast) {
...props,
id,
open: true,
onOpenChange: (open) => {
onOpenChange: (open: boolean) => {
if (!open) dismiss();
},
},

View File

@@ -1,5 +1,5 @@
import { create } from "@bufbuild/protobuf";
import { Protobuf, Types } from "@meshtastic/core";
import { MeshDevice, Protobuf, Types } from "@meshtastic/core";
import { produce } from "immer";
import { createContext, useContext } from "react";
import { create as createStore } from "zustand";
@@ -23,10 +23,22 @@ export type DialogVariant =
| "QR"
| "shutdown"
| "reboot"
| "rebootOTA"
| "deviceName"
| "nodeRemoval"
| "pkiBackup"
| "nodeDetails";
| "nodeDetails"
| "unsafeRoles"
| "refreshKeys";
type QueueStatus = {
res: number, free: number, maxlen: number
}
type NodeError = {
node: number;
error: string;
}
export interface Device {
id: number;
@@ -47,24 +59,31 @@ export interface Device {
number,
Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>[]
>;
connection?: Types.ConnectionType;
nodeErrors: Map<number, NodeError>;
connection?: MeshDevice;
activePage: Page;
activeNode: number;
waypoints: Protobuf.Mesh.Waypoint[];
// currentMetrics: Protobuf.DeviceMetrics;
pendingSettingsChanges: boolean;
messageDraft: string;
queueStatus: QueueStatus,
isQueueingMessages: boolean,
dialog: {
import: boolean;
QR: boolean;
shutdown: boolean;
reboot: boolean;
rebootOTA: boolean;
deviceName: boolean;
nodeRemoval: boolean;
pkiBackup: boolean;
nodeDetails: boolean;
unsafeRoles: boolean;
refreshKeys: boolean;
};
setStatus: (status: Types.DeviceStatusEnum) => void;
setConfig: (config: Protobuf.Config.Config) => void;
setModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => void;
@@ -80,7 +99,7 @@ export interface Device {
addNodeInfo: (nodeInfo: Protobuf.Mesh.NodeInfo) => void;
addUser: (user: Types.PacketMetadata<Protobuf.Mesh.User>) => void;
addPosition: (position: Types.PacketMetadata<Protobuf.Mesh.Position>) => void;
addConnection: (connection: Types.ConnectionType) => void;
addConnection: (connection: MeshDevice) => void;
addMessage: (message: MessageWithState) => void;
addTraceRoute: (
traceroute: Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>,
@@ -96,8 +115,14 @@ export interface Device {
state: MessageState,
) => void;
setDialogOpen: (dialog: DialogVariant, open: boolean) => void;
getDialogOpen: (dialog: DialogVariant) => boolean;
processPacket: (data: ProcessPacketParams) => void;
setMessageDraft: (message: string) => 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 {
@@ -137,6 +162,10 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
activePage: "messages",
activeNode: 0,
waypoints: [],
queueStatus: {
res: 0, free: 0, maxlen: 0
},
isQueueingMessages: false,
dialog: {
import: false,
QR: false,
@@ -146,9 +175,14 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
nodeRemoval: false,
pkiBackup: false,
nodeDetails: false,
unsafeRoles: false,
refreshKeys: false,
rebootOTA: false,
},
pendingSettingsChanges: false,
messageDraft: "",
nodeErrors: new Map(),
setStatus: (status: Types.DeviceStatusEnum) => {
set(
@@ -303,7 +337,7 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
.findIndex(
(wmc) =>
wmc.payloadVariant.case ===
moduleConfig.payloadVariant.case,
moduleConfig.payloadVariant.case,
);
if (workingModuleConfigIndex !== -1) {
device.workingModuleConfig[workingModuleConfigIndex] =
@@ -515,8 +549,6 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
addTraceRoute: (traceroute) => {
set(
produce<DeviceState>((draft) => {
console.log("addTraceRoute called");
console.log(traceroute);
const device = draft.devices.get(id);
if (!device) {
return;
@@ -553,10 +585,8 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
) => {
set(
produce<DeviceState>((draft) => {
console.log("setMessageState called");
const device = draft.devices.get(id);
if (!device) {
console.log("no device found for id");
return;
}
const messageGroup = device.messages[type];
@@ -567,7 +597,6 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
const messages = messageGroup.get(messageIndex);
if (!messages) {
console.log("no messages found for id");
return;
}
@@ -594,6 +623,13 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
}),
);
},
getDialogOpen: (dialog: DialogVariant) => {
const device = get().devices.get(id);
if (!device) {
throw new Error("Device not found");
}
return device.dialog[dialog];
},
processPacket(data: ProcessPacketParams) {
set(
produce<DeviceState>((draft) => {
@@ -631,6 +667,52 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
}),
);
},
setQueueStatus: (status: QueueStatus) => {
set(
produce<DeviceState>((draft) => {
const device = draft.devices.get(id);
if (device) {
device.queueStatus = status;
device.queueStatus.free >= 10 ? true : false
}
}),
);
},
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);
},
});
}),
);

View File

@@ -1,9 +1,9 @@
import type { Device } from "@core/stores/deviceStore.ts";
import { Protobuf, type Types } from "@meshtastic/core";
import { MeshDevice, Protobuf } from "@meshtastic/core";
export const subscribeAll = (
device: Device,
connection: Types.ConnectionType,
connection: MeshDevice,
) => {
let myNodeNum = 0;
@@ -22,15 +22,15 @@ export const subscribeAll = (
) {
return;
}
console.log(`Routing Error: ${routingPacket.data.variant.value}`);
console.info(`Routing Error: ${routingPacket.data.variant.value}`);
break;
}
case "routeReply": {
console.log(`Route Reply: ${routingPacket.data.variant.value}`);
console.info(`Route Reply: ${routingPacket.data.variant.value}`);
break;
}
case "routeRequest": {
console.log(`Route Request: ${routingPacket.data.variant.value}`);
console.info(`Route Request: ${routingPacket.data.variant.value}`);
break;
}
}
@@ -79,6 +79,7 @@ export const subscribeAll = (
device.setModuleConfig(moduleConfig);
});
connection.events.onMessagePacket.subscribe((messagePacket) => {
device.addMessage({
...messagePacket,
@@ -103,4 +104,29 @@ export const subscribeAll = (
time: meshPacket.rxTime,
});
});
connection.events.onQueueStatus.subscribe((queueStatus) => {
device.setQueueStatus(queueStatus);
});
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;
}
}
}
});
};

View File

@@ -0,0 +1,71 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { eventBus } from "@core/utils/eventBus.ts";
describe("EventBus", () => {
beforeEach(() => {
// Reset event listeners before each test
(eventBus as any).listeners = {};
});
it("should register an event listener and trigger it on emit", () => {
const mockCallback = vi.fn();
eventBus.on("dialog:unsafeRoles", mockCallback);
eventBus.emit("dialog:unsafeRoles", { action: "confirm" });
expect(mockCallback).toHaveBeenCalledWith({ action: "confirm" });
});
it("should remove an event listener with off", () => {
const mockCallback = vi.fn();
eventBus.on("dialog:unsafeRoles", mockCallback);
eventBus.off("dialog:unsafeRoles", mockCallback);
eventBus.emit("dialog:unsafeRoles", { action: "dismiss" });
expect(mockCallback).not.toHaveBeenCalled();
});
it("should return an unsubscribe function from on", () => {
const mockCallback = vi.fn();
const unsubscribe = eventBus.on("dialog:unsafeRoles", mockCallback);
unsubscribe();
eventBus.emit("dialog:unsafeRoles", { action: "confirm" });
expect(mockCallback).not.toHaveBeenCalled();
});
it("should allow multiple listeners for the same event", () => {
const mockCallback1 = vi.fn();
const mockCallback2 = vi.fn();
eventBus.on("dialog:unsafeRoles", mockCallback1);
eventBus.on("dialog:unsafeRoles", mockCallback2);
eventBus.emit("dialog:unsafeRoles", { action: "confirm" });
expect(mockCallback1).toHaveBeenCalledWith({ action: "confirm" });
expect(mockCallback2).toHaveBeenCalledWith({ action: "confirm" });
});
it("should only remove the specific listener when off is called", () => {
const mockCallback1 = vi.fn();
const mockCallback2 = vi.fn();
eventBus.on("dialog:unsafeRoles", mockCallback1);
eventBus.on("dialog:unsafeRoles", mockCallback2);
eventBus.off("dialog:unsafeRoles", mockCallback1);
eventBus.emit("dialog:unsafeRoles", { action: "dismiss" });
expect(mockCallback1).not.toHaveBeenCalled();
expect(mockCallback2).toHaveBeenCalledWith({ action: "dismiss" });
});
it("should not fail when calling off on a non-existent listener", () => {
const mockCallback = vi.fn();
eventBus.off("dialog:unsafeRoles", mockCallback);
eventBus.emit("dialog:unsafeRoles", { action: "confirm" });
expect(mockCallback).not.toHaveBeenCalled(); // No error should occur
});
});

View File

@@ -0,0 +1,44 @@
export type EventMap = {
'dialog:unsafeRoles': {
action: 'confirm' | 'dismiss';
};
// add more events as required
};
export type EventName = keyof EventMap;
export type EventCallback<T extends EventName> = (data: EventMap[T]) => void;
class EventBus {
private listeners: { [K in EventName]?: Array<EventCallback<K>> } = {};
public on<T extends EventName>(event: T, callback: EventCallback<T>): () => void {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event]?.push(callback as any);
return () => {
this.off(event, callback);
};
}
public off<T extends EventName>(event: T, callback: EventCallback<T>): void {
if (!this.listeners[event]) return;
const callbackIndex = this.listeners[event]?.indexOf(callback as any);
if (callbackIndex !== undefined && callbackIndex > -1) {
this.listeners[event]?.splice(callbackIndex, 1);
}
}
public emit<T extends EventName>(event: T, data: EventMap[T]): void {
if (!this.listeners[event]) return;
this.listeners[event]?.forEach(callback => {
callback(data);
});
}
}
export const eventBus = new EventBus();

View File

@@ -1,10 +1,12 @@
export function convertIntToIpAddress(int: number): string {
return `${int & 0xff}.${(int >> 8) & 0xff}.${(int >> 16) & 0xff}.${
(int >> 24) & 0xff
}`;
return `${int & 0xff}.${(int >> 8) & 0xff}.${(int >> 16) & 0xff}.${(int >> 24) & 0xff
}`;
}
export function convertIpAddressToInt(ip: string): number | null {
export function convertIpAddressToInt(ip: string): number | undefined {
if (!ip) {
return undefined;
}
return (
ip
.split(".")

View File

@@ -71,6 +71,7 @@
}
@layer base {
*,
::after,
::before,
@@ -96,11 +97,23 @@
}
}
@layer utilities {
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
}
/* Prevent image dragging */
img {
-webkit-user-drag: none;
}
@keyframes spin-slower {
to {
transform: rotate(360deg);
@@ -109,4 +122,4 @@ img {
.animate-spin-slow {
animation: spin-slower 2s linear infinite;
}
}

View File

@@ -1,4 +1,3 @@
import { scan } from "react-scan";
import "@app/index.css";
import { enableMapSet } from "immer";
import "maplibre-gl/dist/maplibre-gl.css";
@@ -7,13 +6,6 @@ import { createRoot } from "react-dom/client";
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 root = createRoot(container);

Some files were not shown because too many files have changed in this diff Show More