129 Commits

Author SHA1 Message Date
philon-
513a285fee Feature: Node favourites (and ignores) (#618)
* Add favourite icon to node Avatar

* Favourites WIP

* Save isFavorite and isIgnored to device

* Fix spelling

* Clean up

* Always sort favorites first

* Add unread count to "Messages" top level menu

* Renaming, UI tweaks

* Add hook tests

* Handle undefined node better
2025-05-13 08:45:24 -04:00
philon-
b2bb3baa01 Add check for undefined hopsAway (#616)
* Add check for undefined hopsAway

Better UI for already "fixed" #351

* Change conditional
2025-05-12 11:54:06 -04:00
Aria Stewart
3f933dd166 Remove case-duplicated sidebarButton.tsx (#617) 2025-05-11 20:01:43 -04:00
Dan Ditomaso
1180b9afb0 fix: moved all svgs into public / dist folder (#615) 2025-05-09 20:57:38 -04:00
philon-
b4ce6efd7b Fix node list sorting, dark mode text colors (#613)
* Fix node list sorting, dark mode text colors

* Update tests

* Remove unused import

* Fix warnings for missing DialogDescription
2025-05-08 12:54:18 -04:00
Dan Ditomaso
da0ada925f fix: removed overly complex scrolling logic in channel chat (#612) 2025-05-06 12:40:07 -04:00
Dan Ditomaso
f9346931f8 update readme with deployment schedule (#611) 2025-05-06 10:10:24 -04:00
Dan Ditomaso
1d18abf6c1 fix: connect new node no longer crashes. (#610) 2025-05-06 10:08:42 -04:00
Dan Ditomaso
a644d30228 Add github action to release to stable (#590)
* feat: added github action workflow to release to stable

* updated trigger logic
2025-05-05 13:20:49 -04:00
Dan Ditomaso
1c453e2981 fix: couple of UI updates, fix: add pre release support to release workflow (#609) 2025-05-05 11:47:36 -04:00
philon-
170abfcc2f fix: Adjust table odd/even colors (#606) 2025-05-05 11:23:37 -04:00
philon-
0539b15ddc Fix #607 (#608) 2025-05-04 20:08:16 -04:00
Dan Ditomaso
bbadb1a917 chore: optimize deno workflow for CI/CD (#605)
* chore: optimize deno workflow for CI/CD

* add install step
2025-05-04 16:39:19 -04:00
Dan Ditomaso
480ca46a95 chore: lint/format all files (#604)
* chore: lint/format all files

* Fix config sidebar button state (#602)

* chore: Update deno.lock version and add Radix UI slider component (#601)

* fix: improve how table addresses even/odd rows

---------

Co-authored-by: philon- <philon-@users.noreply.github.com>
Co-authored-by: Kamil Dzieniszewski <kamil.dzieniszewski@gmail.com>
2025-05-04 15:55:04 -04:00
Kamil Dzieniszewski
b7bdb1a502 chore: Update deno.lock version and add Radix UI slider component (#601) 2025-05-04 15:29:34 -04:00
philon-
3be528d03a Fix config sidebar button state (#602) 2025-05-04 15:28:47 -04:00
Dan Ditomaso
ebc386cfa5 Merge pull request #599 from danditomaso/fix/update-telemetry-labels
fix: updated telemetry field labels
2025-05-04 11:50:58 -04:00
Dan Ditomaso
35f3a347ba fix: updated telemtry field labels 2025-05-03 21:27:26 -04:00
Dan Ditomaso
cfcc9f82d8 Merge pull request #596 from philon-/style-fixes
Minor style fixes
2025-05-03 13:28:22 -04:00
Dan Ditomaso
7ab4254cd0 Merge pull request #597 from danditomaso/fix/content-overflow-issue
fix: defined height to html/body elements
2025-05-03 13:27:54 -04:00
Dan Ditomaso
e3857e61c6 fix: defined height to html/body elements 2025-05-03 13:24:00 -04:00
philon-
a02579d6dd Minor style fixes 2025-05-03 00:27:12 +02:00
Dan Ditomaso
79910dfec7 fix: removed console logs in code (#592) 2025-05-02 11:48:18 -04:00
Austin
b40540b118 Fix upload build.tar (#591) 2025-05-02 10:57:17 -04:00
Dan Ditomaso
99711fc44e Remove duplicate node logic, UI Update, flatten build output (#586)
* refactor nodes to getNodes fn. ui updates

* fixed several styling issues

* fix: message specific styling/overflow

* added footer, fixed tests. styling

* fix: added theme support back to app component

* fix: hide emojis/reactions

* fix: added more padding to content element

* fix: fixed padding in content element

* updated color scheme

* fix: more dark mode styling improvements

* fix: padding and alignment fixes

* fix: prevent left sidebar collapse, added battery component

* fix: change scrollbars to "tiny" style, improved message scrolling, fixed bug with message input

* message store fixes, ui fixes

* fix: disabled message persistance until after release
2025-05-02 09:00:24 -04:00
Dan Ditomaso
3eafad7261 Merge pull request #585 from philon-/feature/map-filtering
Map filtering improvements
2025-04-29 15:29:06 -04:00
Jeremy Gallant
80905d9d29 Change colors to slate 2025-04-29 15:35:34 +02:00
Jeremy Gallant
34db0da87c Add map filter groups / more filters / update UI 2025-04-28 17:38:58 +02:00
Jeremy Gallant
ff33554716 Add hover: and active: styling 2025-04-24 20:30:31 +02:00
Jeremy Gallant
f399d17721 Added map filter indication
Added map filter indication
+ some more type fixes...
2025-04-24 20:12:41 +02:00
Dan Ditomaso
ce71f22316 Merge pull request #580 from philon-/feature/map-filtering
Add filters to node map
2025-04-24 08:52:55 -04:00
Dan Ditomaso
1f3f76373d Merge pull request #583 from danditomaso/fix-remove-assets-output-dir
fix: keep js and css assets in same output directory
2025-04-24 07:53:39 -04:00
Dan Ditomaso
c5fe2f5e68 fix: dont put js and css assets in sub-directory in output 2025-04-23 23:12:54 -04:00
Dan Ditomaso
c050998f3d Merge pull request #582 from meshtastic/danditomaso-patch-1
Update ci.yml
2025-04-23 15:48:37 -04:00
Dan Ditomaso
4802a8f6e6 Update ci.yml 2025-04-23 14:14:00 -04:00
Jeremy Gallant
03e516e568 Slider - additional props 2025-04-23 16:41:20 +02:00
Jeremy Gallant
ef37397969 Remove ! 2025-04-23 07:10:04 +02:00
Jeremy Gallant
c6d122008b Unique id 2025-04-22 23:26:49 +02:00
Jeremy Gallant
3dce031f8e Stricter typing, adjuster colors, mandatory props 2025-04-22 23:18:26 +02:00
philon-
91d8776637 Merge branch 'meshtastic:master' into feature/map-filtering 2025-04-22 20:28:05 +02:00
Dan Ditomaso
01f242b7c3 Merge pull request #577 from philon-/fix/495
Fix #495 - Rescale traceroute SNR
2025-04-22 14:09:45 -04:00
Jeremy Gallant
d5cf71c840 Add filters to node map 2025-04-22 19:04:42 +02:00
Jeremy Gallant
5ba70d9764 Fix #495 - Rescale traceroute SNR 2025-04-19 22:14:03 +02:00
Dan Ditomaso
673476d773 Merge pull request #573 from danditomaso/feat/add-copy-text-to-input
Add Copy to Clipboard option for input fields.
2025-04-13 17:09:40 -04:00
Dan Ditomaso
5eb9fda015 Merge pull request #574 from danditomaso/issue-557-transparent-background
fix: removed transparent toast background
2025-04-13 11:39:28 -04:00
Dan Ditomaso
81586caea0 fix: removed transparent toast background 2025-04-13 08:07:17 -04:00
Dan Ditomaso
a195126df1 fixed when grid collapose's to single column 2025-04-12 22:05:42 -04:00
Dan Ditomaso
b3783bab40 added longer field length 2025-04-12 21:59:58 -04:00
Dan Ditomaso
33d0f93e68 Merge branch 'master' into feat/add-copy-text-to-input 2025-04-12 21:36:11 -04:00
Dan Ditomaso
2050b05d6a feat: add copy text option to input fields. 2025-04-12 21:11:52 -04:00
Dan Ditomaso
38754b9d1a Merge pull request #572 from danditomaso/fix/improve-responsiveness
Improve form responsiveness
2025-04-12 20:26:44 -04:00
Dan Ditomaso
1867484032 fix: improve-responsiveness 2025-04-12 12:46:01 -04:00
Dan Ditomaso
b52ed19649 Merge pull request #571 from danditomaso/fix/improve-refresh-keys-dialog
fix: improvements to refresh dialog
2025-04-10 23:09:41 -04:00
Dan Ditomaso
d53ababf7d fix: improvements to refresh dialog 2025-04-10 16:51:29 -04:00
Hunter Thornsberry
ff43763721 Merge pull request #568 from Hunter275/clock-settings 2025-04-10 13:30:52 -04:00
Hunter Thornsberry
c44d7633f2 Merge pull request #570 from danditomaso/fix/minus-node-num 2025-04-10 13:30:09 -04:00
Dan Ditomaso
08d641eb42 fix: ensured node number on loading screen cant be negitive. 2025-04-10 12:19:15 -04:00
Hunter275
a243a044b9 add use12hClock support 2025-04-10 01:48:32 -04:00
Dan Ditomaso
c95a819eaf Merge pull request #566 from danditomaso/issue-564-multiple-connect-clicks-on-connect-dialog
Multiple clicks on "Connect" button, creates multiple nodes.
2025-04-08 08:20:36 -04:00
Dan Ditomaso
ce5ae675ea fix: hoisted state to parent in order to coordinate connection 2025-04-07 20:27:51 -04:00
Dan Ditomaso
0828618c0d Merge pull request #563 from danditomaso/issue-550-incorrect-text-in-dialog
Incorrect text in PKI dialog
2025-04-07 20:19:33 -04:00
Dan Ditomaso
7267101021 fix: extended dialog to allow for dynamic title/description 2025-04-07 15:02:12 -04:00
Dan Ditomaso
0e868cef58 renamed dialog, added reactions menu 2025-04-07 11:42:15 -04:00
Dan Ditomaso
e410ccb2f4 Merge pull request #562 from danditomaso/fix/missing-import
fix: added missing imports
2025-04-05 13:01:19 -04:00
Dan Ditomaso
c5b3f2ece6 fix: added missing imports 2025-04-05 12:57:42 -04:00
Dan Ditomaso
35353c58cb Merge pull request #561 from danditomaso/fix/failing-test
fix: refactor to fix merge issues with  messageStore and unread counts
2025-04-05 11:49:11 -04:00
Dan Ditomaso
e80d8e73ae fix: refactor to fix merge issues with messageStore and unread counts 2025-04-05 11:46:32 -04:00
Dan Ditomaso
494a35a0c3 Merge pull request #497 from Hunter275/unread-counts
Unread Counts
2025-04-05 08:40:54 -04:00
Dan Ditomaso
818bbb4a30 fix broken test 2025-04-05 08:39:27 -04:00
Dan Ditomaso
4755c0eeb9 refactor to integrate messageStore and unreadCounts 2025-04-04 22:22:35 -04:00
Dan Ditomaso
c8c89fdc95 Merge branch 'master' into unread-counts 2025-04-04 08:58:13 -04:00
Dan Ditomaso
52e0924f1c Merge pull request #560 from danditomaso/fix/node-detail-use-message-store
fix: update node details page to use message store
2025-04-03 22:42:48 -04:00
Dan Ditomaso
645c758b42 fixed: removed unneeded prop 2025-04-03 22:41:10 -04:00
Dan Ditomaso
4dc7788981 fixed typo 2025-04-03 22:40:30 -04:00
Dan Ditomaso
9fa945a863 fix: update node details page to use message store 2025-04-03 22:37:50 -04:00
Dan Ditomaso
38b8695441 Merge pull request #536 from danditomaso/add-message-persistance
Add message persistence using IndexedDB
2025-04-03 22:28:25 -04:00
Dan Ditomaso
eadadb5d1d keyed conversations against from/to, updated tests 2025-04-03 17:03:08 -04:00
Dan Ditomaso
5f424e2e0b Merge branch 'master' into add-message-persistance 2025-04-02 18:02:57 -04:00
James Thomas
d807cd2de7 Using existing Link component and standardizing colors 2025-04-02 18:00:44 -04:00
James Thomas
0b4e3a8da9 Cleanup and add link to official docs 2025-04-02 18:00:44 -04:00
James Thomas
31be5e9a25 Adding connection failure warning 2025-04-02 18:00:44 -04:00
vidplace7
367538eeea GHA: Attach build.tar to release 2025-04-02 18:00:33 -04:00
Dan Ditomaso
442c1cb5f1 fix: moved meshtastic packages into package.json 2025-04-02 18:00:33 -04:00
Dan Ditomaso
a333e4524f updated deps 2025-04-02 18:00:12 -04:00
Hunter275
1e54f7d99b fix for non-Primary channels 2025-04-02 17:59:59 -04:00
Dan Ditomaso
9f2aa8282d adding tests 2025-04-02 17:59:59 -04:00
Dan Ditomaso
8d5dc440d0 feat: add udp over mesh toggle 2025-04-02 17:59:59 -04:00
Dan Ditomaso
8fffde0165 wip 2025-04-02 17:59:59 -04:00
James Thomas
1a6e99971a Extended loading 2025-04-02 17:58:40 -04:00
Dan Ditomaso
4de88c3add fixed: import issue 2025-04-02 17:58:40 -04:00
Dan Ditomaso
76374893e3 added tests 2025-04-02 17:58:40 -04:00
Dan Ditomaso
edc17b304a feat: added reboot to OTA in command menu 2025-04-02 17:58:40 -04:00
Dan Ditomaso
ec7b4528f6 added reboot to command menu 2025-04-02 17:58:30 -04:00
Dan Ditomaso
8d75c4afb1 fix: docker build process 2025-04-02 17:58:30 -04:00
James Thomas
b30fbf90b9 Prevent tooltip from appearing by default 2025-04-02 17:58:30 -04:00
James Thomas
8fb95e1b06 Lint 2025-04-02 17:58:30 -04:00
James Thomas
f5e1a0569f Lint 2025-04-02 17:58:30 -04:00
James Thomas
929f87b411 Adding DM from Map function 2025-04-02 17:58:30 -04:00
Dan Ditomaso
59d97008f2 feat: added tzdef to device config 2025-04-02 17:58:30 -04:00
Dan Ditomaso
540b8ebb4d Merge pull request #548 from James9074/tls-warning
Adding UX Feedback For Failed Device Connections Over HTTP/s
2025-04-02 13:12:39 -04:00
Dan Ditomaso
109d4afce2 Merge pull request #556 from vidplace7/release-buildtar
GHA: Attach build.tar to release
2025-04-02 10:09:17 -04:00
vidplace7
aab8bce78e GHA: Attach build.tar to release 2025-04-02 09:18:20 -04:00
James Thomas
d2c33b4caf Merge branch 'meshtastic:master' into tls-warning 2025-04-02 08:32:23 -04:00
Dan Ditomaso
6443544a6b added dialog to warn before clearing all messages 2025-04-01 14:50:41 -04:00
Dan Ditomaso
a56ac84186 add enums, improve tests, add styling 2025-03-31 21:28:58 -04:00
James Thomas
443a9ea101 Merge branch 'meshtastic:master' into tls-warning 2025-03-31 09:29:45 -04:00
James Thomas
0faafe8bc4 Using existing Link component and standardizing colors 2025-03-30 21:43:25 -04:00
James Thomas
9948701127 Cleanup and add link to official docs 2025-03-30 21:01:25 -04:00
James Thomas
ffae92d233 Adding connection failure warning 2025-03-30 20:52:04 -04:00
Dan Ditomaso
74db087d7d updated node details with new messaging features. 2025-03-28 21:22:10 -04:00
Dan Ditomaso
e00239562c Update src/components/PageComponents/Config/Position.tsx
Co-authored-by: James Thomas <james9074@gmail.com>
2025-03-28 21:14:42 -04:00
Dan Ditomaso
8df67bf76a Merge branch 'master' into add-message-persistance 2025-03-26 16:09:12 -04:00
Dan Ditomaso
80d4670204 state store cleanup, added tests 2025-03-26 15:22:14 -04:00
Dan Ditomaso
ed2ab36ed4 feat: added message persistance 2025-03-25 15:23:24 -04:00
Dan Ditomaso
0d6c5878fc WIP 2025-03-24 15:37:51 -04:00
Hunter275
dab76df131 reorder tests so they don't step on each other 2025-03-21 23:43:57 -04:00
Hunter Thornsberry
d1c19d9d3e add hasNodeError for tests 2025-03-21 14:20:27 -04:00
Hunter Thornsberry
11b052e5bb Merge branch 'master' into unread-counts 2025-03-21 10:36:09 -04:00
Hunter Thornsberry
7e1ba42873 remove defined css class and just use tailwind 2025-03-16 19:45:27 -04:00
Hunter Thornsberry
0bef82ec32 don't update unred if the channel/dm is active 2025-03-14 15:44:02 -04:00
Hunter Thornsberry
f80bb6c42d DM vs Channel message detection 2025-03-14 15:28:45 -04:00
Hunter Thornsberry
01a74829fc Merge branch 'master' into unread-counts 2025-03-11 16:25:58 -04:00
Hunter Thornsberry
7b77b7f5e9 remove test values 2025-03-11 16:21:44 -04:00
Hunter Thornsberry
ce8fcd2269 simplify device list and tests 2025-03-11 16:20:22 -04:00
Hunter Thornsberry
f6f64eca10 tests 2025-03-10 23:33:39 -04:00
Hunter275
c103d7012b tests 2025-03-10 20:54:42 -04:00
Hunter275
1f1a3c5de8 spread on the map 2025-03-09 00:57:52 -05:00
Hunter Thornsberry
3bfd96defe wip 2025-03-07 23:59:52 -05:00
Hunter275
cad590f993 wip 2025-03-07 22:01:34 -05:00
184 changed files with 7666 additions and 3561 deletions

View File

@@ -1,8 +1,9 @@
name: CI
name: Push to Master/Main CI
on:
push:
branches:
- main
- master
permissions:
@@ -24,6 +25,25 @@ jobs:
- name: Install Dependencies
run: deno install
- name: Cache Deno dependencies
uses: actions/cache@v4
with:
path: |
~/.cache/deno
./deno.lock
key: ${{ runner.os }}-deno-${{ hashFiles('**/deno.lock') }}
restore-keys: |
${{ runner.os }}-deno-
- name: Cache Dependencies
run: deno cache src/index.tsx
- name: Run linter
run: deno task lint
- name: Check formatter
run: deno task format --check
- name: Run tests
run: deno task test

View File

@@ -1,6 +1,7 @@
name: Pull Request
name: Pull Request CI
on: pull_request
on:
pull_request:
jobs:
build:
@@ -14,9 +15,28 @@ jobs:
with:
deno-version: v2.x
- name: Cache Deno dependencies
uses: actions/cache@v4
with:
path: |
~/.cache/deno
./deno.lock
key: ${{ runner.os }}-deno-${{ hashFiles('**/deno.lock') }}
restore-keys: |
${{ runner.os }}-deno-
- name: Install Dependencies
run: deno install
- name: Cache Dependencies
run: deno cache src/index.tsx
- name: Run linter
run: deno task lint
- name: Check formatter
run: deno task format --check
- name: Run tests
run: deno task test

View File

@@ -1,8 +1,8 @@
name: 'Release'
name: Release
on:
release:
types: [released]
types: [released, prereleased]
permissions:
contents: write
@@ -38,6 +38,12 @@ jobs:
name: build
path: dist/build.tar
- name: Attach build.tar to release
run: |
gh release upload ${{ github.event.release.tag_name }} dist/build.tar
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3

View File

@@ -0,0 +1,50 @@
name: Update Stable Branch from Master on Latest Release
on:
release:
types: [released]
permissions:
contents: write
jobs:
update-stable-branch:
name: Update Stable Branch from Master
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Configure Git
run: |
git config user.name "GitHub Actions Bot"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Fetch latest master and stable branches
run: |
git fetch origin master:master
git fetch origin stable:stable || echo "Stable branch not found remotely, will create."
- name: Get latest master commit SHA
id: get_master_sha
run: echo "MASTER_SHA=$(git rev-parse master)" >> $GITHUB_ENV
- name: Check out stable branch
run: |
if git show-ref --verify --quiet refs/heads/stable; then
git checkout stable
git pull origin stable # Sync with remote stable if it exists
else
echo "Creating local stable branch based on master HEAD."
git checkout -b stable ${{ env.MASTER_SHA }}
fi
- name: Reset stable branch to latest master
run: git reset --hard ${{ env.MASTER_SHA }}
- name: Force push stable branch
run: git push origin stable --force

View File

@@ -50,11 +50,20 @@ docker run -d -p 8080:8080 --restart always --name Meshtastic-Web ghcr.io/meshta
podman run -d -p 8080:8080 --restart always --name Meshtastic-Web ghcr.io/meshtastic/web
```
## Nightly releases
## Release Schedule
Our nightly releases provide the latest development builds with cutting-edge
features and fixes. These builds are automatically generated from the latest
main branch every night and are available for testing and early adoption.
Our release process follows these guidelines:
- **Versioning:** We use Semantic Versioning (`Major.Minor.Patch`).
- **Stable Releases:** Published around the beginning of each month (e.g.,
`v2.3.4`).
- **Pre-releases:** A pre-release is typically issued mid-month for testing and
early adoption.
- **Nightly Builds:** An experimental Docker image containing the latest
cutting-edge features and fixes is automatically built nightly from the
`master` branch.
### Nightly Builds
```bash
# With Docker
@@ -73,7 +82,7 @@ podman run -d -p 8080:8080 --restart always --name Meshtastic-Web ghcr.io/meshta
> new features
> - No guarantee of backward compatibility between nightly builds
### Version Information
#### Version Information
Each nightly build is tagged with:

View File

@@ -28,6 +28,19 @@
],
"strictPropertyInitialization": false
},
"fmt": {
"exclude": [
"*.test.ts",
"*.test.tsx"
]
},
"lint": {
"exclude": [
"*.test.ts",
"*.test.tsx"
],
"report": "pretty"
},
"unstable": [
"sloppy-imports"
]

1868
deno.lock generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -40,70 +40,72 @@
"@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",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-menubar": "^1.1.6",
"@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-scroll-area": "^1.2.3",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-toast": "^1.2.6",
"@radix-ui/react-tooltip": "^1.1.8",
"@noble/curves": "^1.9.0",
"@radix-ui/react-accordion": "^1.2.8",
"@radix-ui/react-checkbox": "^1.2.3",
"@radix-ui/react-dialog": "^1.1.11",
"@radix-ui/react-dropdown-menu": "^2.1.12",
"@radix-ui/react-label": "^2.1.4",
"@radix-ui/react-menubar": "^1.1.12",
"@radix-ui/react-popover": "^1.1.11",
"@radix-ui/react-scroll-area": "^1.2.6",
"@radix-ui/react-select": "^2.2.2",
"@radix-ui/react-separator": "^1.1.4",
"@radix-ui/react-slider": "^1.3.2",
"@radix-ui/react-switch": "^1.2.2",
"@radix-ui/react-tabs": "^1.1.9",
"@radix-ui/react-toast": "^1.2.11",
"@radix-ui/react-tooltip": "^1.2.4",
"@turf/turf": "^7.2.0",
"base64-js": "^1.5.1",
"class-validator": "^0.14.1",
"class-validator": "^0.14.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"crypto-random-string": "^5.0.0",
"idb-keyval": "^6.2.1",
"immer": "^10.1.1",
"js-cookie": "^3.0.5",
"lucide-react": "^0.486.0",
"maplibre-gl": "5.3.0",
"lucide-react": "^0.507.0",
"maplibre-gl": "5.4.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-error-boundary": "^5.0.0",
"react-hook-form": "^7.55.0",
"react-map-gl": "8.0.2",
"react-error-boundary": "^6.0.0",
"react-hook-form": "^7.56.2",
"react-map-gl": "8.0.4",
"react-qrcode-logo": "^3.0.0",
"rfc4648": "^1.5.4",
"vite-plugin-node-polyfills": "^0.23.0",
"zod": "^3.24.2",
"zustand": "5.0.3"
"zod": "^3.24.3",
"zustand": "5.0.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.0",
"@tailwindcss/postcss": "^4.1.5",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/chrome": "^0.0.313",
"@types/chrome": "^0.0.318",
"@types/js-cookie": "^3.0.6",
"@types/node": "^22.13.17",
"@types/react": "^19.0.12",
"@types/react-dom": "^19.0.4",
"@types/serviceworker": "^0.0.127",
"@types/node": "^22.15.3",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.3",
"@types/serviceworker": "^0.0.133",
"@types/w3c-web-serial": "^1.0.8",
"@types/web-bluetooth": "^0.0.21",
"@vitejs/plugin-react": "^4.3.4",
"@vitejs/plugin-react": "^4.4.1",
"autoprefixer": "^10.4.21",
"gzipper": "^8.2.1",
"happy-dom": "^17.4.4",
"happy-dom": "^17.4.6",
"postcss": "^8.5.3",
"simple-git-hooks": "^2.12.1",
"tailwind-merge": "^3.1.0",
"tailwindcss": "^4.1.0",
"simple-git-hooks": "^2.13.0",
"tailwind-merge": "^3.2.0",
"tailwindcss": "^4.1.5",
"tailwindcss-animate": "^1.0.7",
"tar": "^7.4.3",
"testing-library": "^0.0.2",
"typescript": "^5.8.2",
"vite": "^6.2.4",
"vitest": "^3.1.1",
"typescript": "^5.8.3",
"vite": "^6.3.4",
"vitest": "^3.1.2",
"vite-plugin-pwa": "^1.0.0"
}
}

16
public/Logo.svg Normal file
View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8" standalone="no" ?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="512" height="512" viewBox="0 0 512 512" xml:space="preserve">
<desc>Created with Fabric.js 4.6.0</desc>
<defs>
</defs>
<g transform="matrix(1 0 0 1 256 256)" id="xYQ9Gk9Jwpgj_HMOXB3F_" >
<path style="stroke: rgb(213,130,139); stroke-width: 0; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(103,234,148); fill-rule: nonzero; opacity: 1;" vector-effect="non-scaling-stroke" transform=" translate(-256, -256)" d="M 0 0 L 512 0 L 512 512 L 0 512 z" stroke-linecap="round" />
</g>
<g transform="matrix(1.79 0 0 1.79 313.74 258.36)" id="1xBsk2n9FZp60Rz1O-ceJ" >
<path style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: round; stroke-miterlimit: 2; fill: rgb(44,45,60); fill-rule: evenodd; opacity: 1;" vector-effect="non-scaling-stroke" transform=" translate(-250.97, -362.41)" d="M 250.908 330.267 L 193.126 415.005 L 180.938 406.694 L 244.802 313.037 C 246.174 311.024 248.453 309.819 250.889 309.816 C 253.326 309.814 255.606 311.015 256.982 313.026 L 320.994 406.536 L 308.821 414.869 L 250.908 330.267 Z" stroke-linecap="round" />
</g>
<g transform="matrix(1.81 0 0 1.81 145 256.15)" id="KxN7E9YpbyPgz0S4z4Cl6" >
<path style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: round; stroke-miterlimit: 2; fill: rgb(44,45,60); fill-rule: evenodd; opacity: 1;" vector-effect="non-scaling-stroke" transform=" translate(-115.14, -528.06)" d="M 87.642 581.398 L 154.757 482.977 L 142.638 474.713 L 75.523 573.134 L 87.642 581.398 Z" stroke-linecap="round" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -1,5 +0,0 @@
# Copyright Notice
Copyright © 2024 Meshtastic LLC. All Rights Reserved.
## In reference to the GNU GPLv3 License terms defined in Section 7e
Images (or assets) in this directory are protected under international copyright laws and treaties. Unauthorized reproduction, distribution, modification, or use of these images in any form, commercial or otherwise, outside of official Meshtastic creative works or its Backers and Partners is strictly prohibited without prior written consent from the copyright holder (Meshtastic LLC).

View File

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 89 KiB

View File

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

View File

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

View File

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

View File

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

View File

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 9.9 KiB

View File

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 9.9 KiB

View File

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 83 KiB

View File

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 83 KiB

View File

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 43 KiB

View File

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

View File

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 102 KiB

View File

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 71 KiB

View File

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 164 KiB

View File

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 128 KiB

View File

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

View File

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 76 KiB

View File

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB

View File

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

View File

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

View File

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

View File

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

View File

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

View File

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 112 KiB

View File

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

View File

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

View File

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

View File

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

View File

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

View File

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 91 KiB

View File

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 120 KiB

View File

@@ -1,6 +1,5 @@
import { DeviceWrapper } from "@app/DeviceWrapper.tsx";
import { PageRouter } from "@app/PageRouter.tsx";
import { DeviceSelector } from "@components/DeviceSelector.tsx";
import { DialogManager } from "@components/Dialog/DialogManager.tsx";
import { NewDeviceDialog } from "@components/Dialog/NewDeviceDialog.tsx";
import { KeyBackupReminder } from "@components/KeyBackupReminder.tsx";
@@ -14,7 +13,8 @@ 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";
import { SidebarProvider } from "@core/stores/sidebarStore.tsx";
import { useTheme } from "@core/hooks/useTheme.ts";
export const App = (): JSX.Element => {
const { getDevice } = useDeviceStore();
@@ -23,6 +23,9 @@ export const App = (): JSX.Element => {
const device = getDevice(selectedDevice);
// Sets up light/dark mode based on user preferences or system settings
useTheme();
return (
<ErrorBoundary FallbackComponent={ErrorPage}>
<NewDeviceDialog
@@ -33,27 +36,31 @@ export const App = (): JSX.Element => {
/>
<Toaster />
<DeviceWrapper device={device}>
<div className="flex h-screen flex-col overflow-hidden bg-background-primary text-text-primary">
<div className="flex grow">
<DeviceSelector />
<div className="flex grow flex-col">
{device ? (
<div className="flex h-screen w-full">
<DialogManager />
<KeyBackupReminder />
<CommandPalette />
<MapProvider>
<PageRouter />
</MapProvider>
</div>
) : (
<>
<Dashboard />
<Footer />
</>
)}
<div
className="flex h-screen flex-col bg-background-primary text-text-primary"
style={{ scrollbarWidth: "thin" }}
>
<SidebarProvider>
<div className="h-full flex flex-col">
{device
? (
<div className="h-full flex w-full">
<DialogManager />
<KeyBackupReminder />
<CommandPalette />
<MapProvider>
<PageRouter />
</MapProvider>
</div>
)
: (
<>
<Dashboard />
<Footer />
</>
)}
</div>
</div>
</SidebarProvider>
</div>
</DeviceWrapper>
</ErrorBoundary>

View File

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

View File

@@ -1,6 +1,19 @@
import { vi } from 'vitest'
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} />
}));
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

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

View File

@@ -1,6 +1,15 @@
import { vi } from 'vitest'
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>
}));
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

@@ -1,7 +1,11 @@
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>
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

@@ -0,0 +1,88 @@
import React from "react";
import {
BatteryFullIcon,
BatteryLowIcon,
BatteryMediumIcon,
PlugZapIcon,
} from "lucide-react";
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
interface DeviceMetrics {
batteryLevel?: number | null;
voltage?: number | null;
}
interface BatteryStatusProps {
deviceMetrics?: DeviceMetrics | null;
}
interface BatteryStateConfig {
condition: (level: number) => boolean;
Icon: React.ElementType;
className: string;
text: (level: number) => string;
}
const batteryStates: BatteryStateConfig[] = [
{
condition: (level) => level > 100,
Icon: PlugZapIcon,
className: "text-gray-500",
text: () => "Plugged in",
},
{
condition: (level) => level > 80,
Icon: BatteryFullIcon,
className: "text-green-500",
text: (level) => `${level}% charging`,
},
{
condition: (level) => level > 20,
Icon: BatteryMediumIcon,
className: "text-yellow-500",
text: (level) => `${level}% charging`,
},
{
condition: () => true,
Icon: BatteryLowIcon,
className: "text-red-500",
text: (level) => `${level}% charging`,
},
];
const getBatteryState = (level: number) => {
return batteryStates.find((state) => state.condition(level));
};
const BatteryStatus: React.FC<BatteryStatusProps> = ({ deviceMetrics }) => {
if (
deviceMetrics?.batteryLevel === undefined ||
deviceMetrics?.batteryLevel === null
) {
return null;
}
const { batteryLevel, voltage } = deviceMetrics;
const currentState = getBatteryState(batteryLevel) ??
batteryStates[batteryStates.length - 1];
const BatteryIcon = currentState.Icon;
const iconClassName = currentState.className;
const statusText = currentState.text(batteryLevel);
const voltageTitle = `${voltage?.toPrecision(3) ?? "Unknown"} volts`;
return (
<div
className="flex items-center gap-1 mt-0.5 text-gray-500"
title={voltageTitle}
>
<BatteryIcon size={22} className={iconClassName} />
<Subtle aria-label="Battery">
{statusText}
</Subtle>
</div>
);
};
export default BatteryStatus;

View File

@@ -17,8 +17,10 @@ import {
FactoryIcon,
LayersIcon,
LinkIcon,
type LucideIcon,
MapIcon,
MessageSquareIcon,
Pin,
PlusIcon,
PowerIcon,
QrCodeIcon,
@@ -27,8 +29,6 @@ import {
SmartphoneIcon,
TrashIcon,
UsersIcon,
Pin,
type LucideIcon,
} from "lucide-react";
import { useEffect } from "react";
import { Avatar } from "@components/UI/Avatar.tsx";
@@ -57,11 +57,14 @@ export const CommandPalette = () => {
const {
commandPaletteOpen,
setCommandPaletteOpen,
setConnectDialogOpen,
setSelectedDevice,
} = useAppStore();
const { getDevices } = useDeviceStore();
const { setDialogOpen, setActivePage, connection } = useDevice();
const { pinnedItems, togglePinnedItem } = usePinnedItems({ storageName: 'pinnedCommandMenuGroups' });
const { setDialogOpen, setActivePage, getNode, connection } = useDevice();
const { pinnedItems, togglePinnedItem } = usePinnedItems({
storageName: "pinnedCommandMenuGroups",
});
const groups: Group[] = [
{
@@ -114,15 +117,12 @@ export const CommandPalette = () => {
label: "Switch Node",
icon: ArrowLeftRightIcon,
subItems: getDevices().map((device) => ({
label:
device.nodes.get(device.hardware.myNodeNum)?.user?.longName ??
label: getNode(device.hardware.myNodeNum)?.user?.longName ??
device.hardware.myNodeNum.toString(),
icon: (
<Avatar
text={
device.nodes.get(device.hardware.myNodeNum)?.user?.shortName ??
device.hardware.myNodeNum.toString()
}
text={getNode(device.hardware.myNodeNum)?.user?.shortName ??
device.hardware.myNodeNum.toString()}
/>
),
action() {
@@ -134,7 +134,7 @@ export const CommandPalette = () => {
label: "Connect New Node",
icon: PlusIcon,
action() {
setSelectedDevice(0);
setConnectDialogOpen(true);
},
},
],
@@ -219,10 +219,10 @@ export const CommandPalette = () => {
},
},
{
label: "[WIP] Clear Messages",
label: "Clear All Stored Message",
icon: EraserIcon,
action() {
alert("This feature is not implemented");
setDialogOpen("deleteMessages", true);
},
},
],
@@ -248,7 +248,10 @@ 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>
@@ -262,13 +265,11 @@ export const CommandPalette = () => {
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"
"transition-all duration-300 scale-100 cursor-pointer p-2 focus:*:data-label:opacity-100",
)}
aria-description={
pinnedItems.includes(group.label)
? "Unpin command group"
: "Pin command group"
}
aria-description={pinnedItems.includes(group.label)
? "Unpin command group"
: "Pin command group"}
>
<span
data-label
@@ -280,7 +281,7 @@ export const CommandPalette = () => {
"transition-opacity",
pinnedItems.includes(group.label)
? "opacity-100 text-red-500"
: "opacity-40 hover:opacity-70"
: "opacity-40 hover:opacity-70",
)}
/>
</button>

View File

@@ -1,76 +0,0 @@
import { DeviceSelectorButton } from "@components/DeviceSelectorButton.tsx";
import ThemeSwitcher from "@components/ThemeSwitcher.tsx";
import { Separator } from "@components/UI/Seperator.tsx";
import { Code } from "@components/UI/Typography/Code.tsx";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDeviceStore } from "@core/stores/deviceStore.ts";
import { HomeIcon, PlusIcon, SearchIcon } from "lucide-react";
import { Avatar } from "@components/UI/Avatar.tsx";
export const DeviceSelector = () => {
const { getDevices } = useDeviceStore();
const {
selectedDevice,
setSelectedDevice,
setCommandPaletteOpen,
setConnectDialogOpen,
} = useAppStore();
return (
<nav className="flex flex-col justify-between border-r-[0.5px] border-slate-300 pt-2 dark:border-slate-700">
<div className="flex flex-col overflow-y-hidden">
<ul className="flex w-20 grow flex-col items-center space-y-4 bg-transparent py-4 px-5">
<DeviceSelectorButton
active={selectedDevice === 0}
onClick={() => {
setSelectedDevice(0);
}}
>
<HomeIcon />
</DeviceSelectorButton>
{getDevices().map((device) => (
<DeviceSelectorButton
key={device.id}
onClick={() => {
setSelectedDevice(device.id);
}}
active={selectedDevice === device.id}
>
<Avatar
text={device.nodes
.get(device.hardware.myNodeNum)
?.user?.shortName.toString() ?? "UNK"}
/>
</DeviceSelectorButton>
))}
<Separator />
<button
type="button"
onClick={() => setConnectDialogOpen(true)}
className="transition-all duration-300"
>
<PlusIcon />
</button>
</ul>
</div>
<div className="flex w-20 flex-col items-center space-y-5 px-5 pb-5">
<ThemeSwitcher />
<button
type="button"
className="transition-all hover:text-accent"
onClick={() => setCommandPaletteOpen(true)}
>
<SearchIcon />
</button>
{/* TODO: This is being commented out until its fixed */}
{
/* <button type="button" className="transition-all hover:text-accent">
<LanguagesIcon />
</button> */
}
<Separator />
<Code>{import.meta.env.VITE_COMMIT_HASH}</Code>
</div>
</nav>
);
};

View File

@@ -1,25 +0,0 @@
export interface DeviceSelectorButtonProps {
active: boolean;
onClick: () => void;
children?: React.ReactNode;
}
export const DeviceSelectorButton = ({
onClick,
children,
}: DeviceSelectorButtonProps) => (
<li
className="aspect-w-1 aspect-h-1 relative w-full"
onClick={onClick}
onKeyDown={onClick}
>
{
/* {active && (
<div className="absolute -left-2 h-10 w-1.5 rounded-full bg-accent" />
)} */
}
<div className="flex aspect-square cursor-pointer flex-col items-center justify-center">
{children}
</div>
</li>
);

View File

@@ -0,0 +1,80 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
// Ensure the path is correct for import
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
import { DeleteMessagesDialog } from "@components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx";
vi.mock("@core/stores/messageStore", () => ({
useMessageStore: vi.fn(() => ({
deleteAllMessages: vi.fn(),
})),
}));
describe("DeleteMessagesDialog", () => {
const mockOnOpenChange = vi.fn();
const mockClearAllMessages = vi.fn();
beforeEach(() => {
mockOnOpenChange.mockClear();
mockClearAllMessages.mockClear();
const mockedUseMessageStore = vi.mocked(useMessageStore);
mockedUseMessageStore.mockImplementation(() => ({
deleteAllMessages: mockClearAllMessages,
}));
mockedUseMessageStore.mockClear();
});
it("calls onOpenChange with false when the close button (X) is clicked", () => {
render(
<DeleteMessagesDialog open onOpenChange={mockOnOpenChange} />,
);
const closeButton = screen.queryByTestId("dialog-close-button");
if (!closeButton) {
throw new Error(
"Dialog close button with data-testid='dialog-close-button' not found. Did you add it to the component?",
);
}
fireEvent.click(closeButton);
expect(mockOnOpenChange).toHaveBeenCalledTimes(1);
expect(mockOnOpenChange).toHaveBeenCalledWith(false);
});
it("renders the dialog when open is true", () => {
render(
<DeleteMessagesDialog open onOpenChange={mockOnOpenChange} />,
);
expect(screen.getByText("Clear All Messages")).toBeInTheDocument();
expect(screen.getByText(/This action will clear all message history./))
.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Dismiss" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Clear Messages" }))
.toBeInTheDocument();
});
it("does not render the dialog when open is false", () => {
render(
<DeleteMessagesDialog open={false} onOpenChange={mockOnOpenChange} />,
);
expect(screen.queryByText("Clear All Messages")).toBeNull();
});
it("calls onOpenChange with false when the dismiss button is clicked", () => {
render(
<DeleteMessagesDialog open onOpenChange={mockOnOpenChange} />,
);
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
expect(mockOnOpenChange).toHaveBeenCalledTimes(1); // Add count check
expect(mockOnOpenChange).toHaveBeenCalledWith(false);
});
it("calls deleteAllMessages and onOpenChange with false when the clear messages button is clicked", () => {
render(
<DeleteMessagesDialog open onOpenChange={mockOnOpenChange} />,
);
fireEvent.click(screen.getByRole("button", { name: "Clear Messages" }));
expect(mockClearAllMessages).toHaveBeenCalledTimes(1);
expect(mockOnOpenChange).toHaveBeenCalledTimes(1); // Add count check
expect(mockOnOpenChange).toHaveBeenCalledWith(false);
});
});

View File

@@ -0,0 +1,62 @@
import { Button } from "@components/UI/Button.tsx";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { AlertTriangleIcon } from "lucide-react";
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
export interface DeleteMessagesDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const DeleteMessagesDialog = ({
open,
onOpenChange,
}: DeleteMessagesDialogProps) => {
const { deleteAllMessages } = useMessageStore();
const handleCloseDialog = () => {
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose data-testid="dialog-close-button" />
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangleIcon className="h-5 w-5 text-warning" />
Clear All Messages
</DialogTitle>
<DialogDescription>
This action will clear all message history. This cannot be undone.
Are you sure you want to continue?
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-4">
<Button
variant="outline"
onClick={handleCloseDialog}
>
Dismiss
</Button>
<Button
variant="destructive"
onClick={() => {
deleteAllMessages();
handleCloseDialog();
}}
>
Clear Messages
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -10,10 +10,11 @@ import {
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { Input } from "@components/UI/Input.tsx";
import { Label } from "@components/UI/Label.tsx";
import { Protobuf } from "@meshtastic/core";
import { useForm } from "react-hook-form";
import { GenericInput } from "@components/Form/FormInput.tsx";
import { validateMaxByteLength } from "@core/utils/string.ts";
export interface User {
longName: string;
@@ -24,32 +25,50 @@ export interface DeviceNameDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
const MAX_LONG_NAME_BYTE_LENGTH = 40;
const MAX_SHORT_NAME_BYTE_LENGTH = 4;
export const DeviceNameDialog = ({
open,
onOpenChange,
}: DeviceNameDialogProps) => {
const { hardware, nodes, connection } = useDevice();
const { hardware, getNode, connection } = useDevice();
const myNode = getNode(hardware.myNodeNum);
const myNode = nodes.get(hardware.myNodeNum);
const defaultValues = {
longName: myNode?.user?.longName ?? "Unknown",
shortName: myNode?.user?.shortName ?? "??",
};
const { register, handleSubmit } = useForm<User>({
values: {
longName: myNode?.user?.longName ?? "Unknown",
shortName: myNode?.user?.shortName ?? "Unknown",
},
const { getValues, setValue, reset, control, handleSubmit } = useForm<User>({
values: defaultValues,
});
const { currentLength: currentLongNameLength } = validateMaxByteLength(
getValues("longName"),
MAX_LONG_NAME_BYTE_LENGTH,
);
const { currentLength: currentShortNameLength } = validateMaxByteLength(
getValues("shortName"),
MAX_SHORT_NAME_BYTE_LENGTH,
);
const onSubmit = handleSubmit((data) => {
connection?.setOwner(
create(Protobuf.Mesh.UserSchema, {
...myNode?.user,
...(myNode?.user ?? {}),
...data,
}),
);
onOpenChange(false);
});
const handleReset = () => {
reset({ longName: "", shortName: "" });
setValue("longName", "");
setValue("shortName", "");
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
@@ -60,21 +79,52 @@ export const DeviceNameDialog = ({
The Device will restart once the config is saved.
</DialogDescription>
</DialogHeader>
<div className="gap-4">
<form onSubmit={onSubmit}>
<Label>Long Name</Label>
<Input className="dark:text-slte-900" {...register("longName")} />
<Label>Short Name</Label>
<Input
className="dark:text-slte-900"
maxLength={4}
{...register("shortName")}
<form onSubmit={onSubmit} className="flex flex-col gap-4">
<div>
<Label htmlFor="longName">Long Name</Label>
<GenericInput
control={control}
field={{
name: "longName",
label: "Long Name",
type: "text",
properties: {
className: "text-slate-900 dark:text-slate-200",
fieldLength: {
currentValueLength: currentLongNameLength ?? 0,
max: MAX_LONG_NAME_BYTE_LENGTH,
showCharacterCount: true,
},
},
}}
/>
</form>
</div>
<DialogFooter>
<Button onClick={() => onSubmit()}>Save</Button>
</DialogFooter>
</div>
<div>
<Label htmlFor="shortName">Short Name</Label>
<GenericInput
control={control}
field={{
name: "shortName",
label: "Short Name",
type: "text",
properties: {
fieldLength: {
currentValueLength: currentShortNameLength ?? 0,
max: MAX_SHORT_NAME_BYTE_LENGTH,
showCharacterCount: true,
},
},
}}
/>
</div>
<DialogFooter>
<Button type="button" variant="destructive" onClick={handleReset}>
Reset
</Button>
<Button type="submit">Save</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);

View File

@@ -10,6 +10,7 @@ import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDeta
import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx";
import { RefreshKeysDialog } from "@components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx";
import { RebootOTADialog } from "@components/Dialog/RebootOTADialog.tsx";
import { DeleteMessagesDialog } from "@components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx";
export const DialogManager = () => {
const { channels, config, dialog, setDialogOpen } = useDevice();
@@ -84,6 +85,12 @@ export const DialogManager = () => {
setDialogOpen("rebootOTA", open);
}}
/>
<DeleteMessagesDialog
open={dialog.deleteMessages}
onOpenChange={(open) => {
setDialogOpen("deleteMessages", open);
}}
/>
</>
);
};

View File

@@ -51,7 +51,7 @@ export const ImportDialog = ({
const paddedString = encodedChannelConfig
.padEnd(
encodedChannelConfig.length +
((4 - (encodedChannelConfig.length % 4)) % 4),
((4 - (encodedChannelConfig.length % 4)) % 4),
"=",
)
.replace(/-/g, "+")
@@ -109,7 +109,6 @@ export const ImportDialog = ({
<Input
value={importDialogInput}
suffix={validUrl ? "✅" : "❌"}
className="dark:text-slate-900"
onChange={(e) => {
setImportDialogInput(e.target.value);
}}

View File

@@ -21,9 +21,9 @@ export const LocationResponseDialog = ({
open,
onOpenChange,
}: LocationResponseDialogProps) => {
const { nodes } = useDevice();
const { getNode } = useDevice();
const from = nodes.get(location?.from ?? 0);
const from = getNode(location?.from ?? 0);
const longName = from?.user?.longName ??
(from ? `!${numberToHexUnpadded(from?.num)}` : "Unknown");
const shortName = from?.user?.shortName ??
@@ -43,8 +43,9 @@ 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

@@ -87,8 +87,8 @@ const ErrorMessage = ({ missingFeatures }: FeatureErrorProps) => {
{browserFeatures.length > 0 && (
<>
This connection type requires{" "}
{formatFeatureList(browserFeatures)}. Please use a
supported browser, like Chrome or Edge.
{formatFeatureList(browserFeatures)}. Please use a supported
browser, like Chrome or Edge.
</>
)}
{needsSecureContext && (
@@ -135,7 +135,7 @@ export const NewDeviceDialog = ({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogContent aria-describedby={undefined}>
<DialogClose />
<DialogHeader>
<DialogTitle>Connect New Device</DialogTitle>
@@ -151,10 +151,12 @@ export const NewDeviceDialog = ({
{tabs.map((tab) => (
<TabsContent key={tab.label} value={tab.label}>
<fieldset disabled={tab.isDisabled}>
{tab.isDisabled
{(tab.label !== "HTTP" && tab.isDisabled)
? <ErrorMessage missingFeatures={unsupported} />
: null}
<tab.element closeDialog={() => onOpenChange(false)} />
<tab.element
closeDialog={() => onOpenChange(false)}
/>
</fieldset>
</TabsContent>
))}

View File

@@ -1,14 +1,22 @@
import { describe, it, vi, expect, beforeEach, Mock } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useAppStore } from "@core/stores/appStore.ts";
import { Protobuf } from "@meshtastic/core";
vi.mock("@core/stores/deviceStore");
vi.mock("@core/stores/deviceStore", () => {
return {
useDevice: () => ({
setDialogOpen: vi.fn(),
}),
};
});
vi.mock("@core/stores/appStore");
const mockUseAppStore = vi.mocked(useAppStore);
describe("NodeDetailsDialog", () => {
const mockDevice = {
const mockNode = {
num: 1234,
user: {
longName: "Test Node",
@@ -29,45 +37,96 @@ describe("NodeDetailsDialog", () => {
voltage: 4.2,
uptimeSeconds: 3600,
},
};
} as unknown as Protobuf.Mesh.NodeInfo;
beforeEach(() => {
// Reset mocks before each test
vi.resetAllMocks();
(useDevice as Mock).mockReturnValue({
nodes: new Map([[1234, mockDevice]]),
});
(useAppStore as unknown as Mock).mockReturnValue({
mockUseAppStore.mockReturnValue({
nodeNumDetails: 1234,
});
});
it("renders node details correctly", () => {
render(<NodeDetailsDialog open={true} onOpenChange={() => { }} />);
render(<NodeDetailsDialog open node={mockNode} onOpenChange={() => {}} />);
expect(screen.getByText(/Node Details for Test Node/i)).toBeInTheDocument();
expect(screen.getByText(/Node Details for Test Node \(TN\)/i))
.toBeInTheDocument();
expect(screen.getByText("Node Number: 1234")).toBeInTheDocument();
expect(screen.getByText(/Node Hex: !/i)).toBeInTheDocument();
expect(screen.getByText(/Last Heard:/i)).toBeInTheDocument();
expect(screen.getByText(/Coordinates:/i)).toBeInTheDocument();
const link = screen.getByRole("link", { name: /^45, -75$/ });
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute(
"href",
expect.stringContaining("openstreetmap.org"),
);
expect(screen.getByText(/Altitude: 200m/i)).toBeInTheDocument();
expect(screen.getByText(/Air TX utilization: 50.12%/i)).toBeInTheDocument();
expect(screen.getByText(/Channel utilization: 75.46%/i)).toBeInTheDocument();
expect(screen.getByText(/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();
expect(screen.getByText(/All Raw Metrics:/i)).toBeInTheDocument();
});
it("renders null if device is not found", () => {
(useDevice as Mock).mockReturnValue({
nodes: new Map(),
});
it("renders null if node is undefined", () => {
const mockNode = undefined;
render(<NodeDetailsDialog open={true} onOpenChange={() => { }} />);
const { container } = render(
<NodeDetailsDialog open node={mockNode} onOpenChange={() => {}} />,
);
expect(container.firstChild).toBeNull();
expect(screen.queryByText(/Node Details for/i)).not.toBeInTheDocument();
});
it("renders correctly when position is missing", () => {
const nodeWithoutPosition = { ...mockNode, position: undefined };
render(
<NodeDetailsDialog
open
node={nodeWithoutPosition}
onOpenChange={() => {}}
/>,
);
expect(screen.queryByText(/Coordinates:/i)).not.toBeInTheDocument();
expect(screen.queryByText(/Altitude:/i)).not.toBeInTheDocument();
expect(screen.getByText(/Node Details for Test Node/i)).toBeInTheDocument();
});
it("renders correctly when deviceMetrics are missing", () => {
const nodeWithoutMetrics = { ...mockNode, deviceMetrics: undefined };
render(
<NodeDetailsDialog
open
node={nodeWithoutMetrics}
onOpenChange={() => {}}
/>,
);
expect(screen.queryByText(/Device Metrics:/i)).not.toBeInTheDocument();
expect(screen.queryByText(/Air TX utilization:/i)).not.toBeInTheDocument();
expect(screen.getByText(/Node Details for Test Node/i)).toBeInTheDocument();
});
it("renders 'Never' for lastHeard when timestamp is 0", () => {
const nodeNeverHeard = { ...mockNode, lastHeard: 0 };
render(
<NodeDetailsDialog open node={nodeNeverHeard} onOpenChange={() => {}} />,
);
expect(screen.getByText(/Last Heard: Never/i)).toBeInTheDocument();
});
});

View File

@@ -1,5 +1,21 @@
import { useEffect, useState } from "react";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDevice } from "@core/stores/deviceStore.ts";
import {
MessageType,
useMessageStore,
} from "@core/stores/messageStore/index.ts";
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";
import { toast } from "@core/hooks/useToast.ts";
import { useFavoriteNode } from "../../../core/hooks/useFavoriteNode.ts";
import { useIgnoreNode } from "../../../core/hooks/useIgnoreNode.ts";
import { cn } from "@core/utils/cn.ts";
import {
Accordion,
AccordionContent,
@@ -14,124 +30,282 @@ import {
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";
import { Button } from "@components/UI/Button.tsx";
import {
BellIcon,
BellOffIcon,
MapPinnedIcon,
MessageSquareIcon,
StarIcon,
TrashIcon,
WaypointsIcon,
} from "lucide-react";
import {
Tooltip,
TooltipArrow,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@components/UI/Tooltip.tsx";
import { Separator } from "@components/UI/Seperator.tsx";
export interface NodeDetailsDialogProps {
node: Protobuf.Mesh.NodeInfo | undefined;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const NodeDetailsDialog = ({
node,
open,
onOpenChange,
}: NodeDetailsDialogProps) => {
const { nodes } = useDevice();
const { nodeNumDetails } = useAppStore();
const { setDialogOpen, connection, setActivePage } = useDevice();
const { setNodeNumToBeRemoved } = useAppStore();
const { setChatType, setActiveChat } = useMessageStore();
const device = nodes.get(nodeNumDetails);
const { updateFavorite } = useFavoriteNode();
const [isFavoriteState, setIsFavoriteState] = useState<boolean>(false);
if (!device) return null;
const { updateIgnored } = useIgnoreNode();
const [isIgnoredState, setIsIgnoredState] = useState<boolean>(false);
useEffect(() => {
if (!node) return;
setIsFavoriteState(node?.isFavorite);
setIsIgnoredState(node?.isIgnored);
}, [node]);
if (!node) return;
function handleDirectMessage() {
if (!node) return;
setChatType(MessageType.Direct);
setActiveChat(node.num);
setActivePage("messages");
}
function handleRequestPosition() {
if (!node) return;
toast({
title: "Requesting position, please wait...",
});
connection?.requestPosition(node.num).then(() =>
toast({
title: "Position request sent.",
})
);
onOpenChange(false);
}
function handleTraceroute() {
if (!node) return;
toast({
title: "Sending Traceroute, please wait...",
});
connection?.traceRoute(node.num).then(() =>
toast({
title: "Traceroute sent.",
})
);
onOpenChange(false);
}
function handleNodeRemove() {
if (!node) return;
setNodeNumToBeRemoved(node?.num);
setDialogOpen("nodeRemoval", true);
onOpenChange(false);
}
function handleToggleFavorite() {
if (!node) return;
updateFavorite({ nodeNum: node.num, isFavorite: !isFavoriteState });
setIsFavoriteState(!isFavoriteState);
}
function handleToggleIgnored() {
if (!node) return;
updateIgnored({ nodeNum: node.num, isIgnored: !isIgnoredState });
setIsIgnoredState(!isIgnoredState);
}
const deviceMetricsMap = [
{
key: "airUtilTx",
label: "Air TX utilization",
value: device.deviceMetrics?.airUtilTx,
value: node.deviceMetrics?.airUtilTx,
format: (val: number) => `${val.toFixed(2)}%`,
},
{
key: "channelUtilization",
label: "Channel utilization",
value: device.deviceMetrics?.channelUtilization,
value: node.deviceMetrics?.channelUtilization,
format: (val: number) => `${val.toFixed(2)}%`,
},
{
key: "batteryLevel",
label: "Battery level",
value: device.deviceMetrics?.batteryLevel,
value: node.deviceMetrics?.batteryLevel,
format: (val: number) => `${val.toFixed(2)}%`,
},
{
key: "voltage",
label: "Voltage",
value: device.deviceMetrics?.voltage,
value: node.deviceMetrics?.voltage,
format: (val: number) => `${val.toFixed(2)}V`,
},
];
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent >
<DialogContent aria-describedby={undefined}>
<DialogClose />
<DialogHeader>
<DialogTitle>
Node Details for {device.user?.longName ?? "UNKNOWN"} (
{device.user?.shortName ?? "UNK"})
Node Details for {node.user?.longName ?? "UNKNOWN"} (
{node.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 className="flex flex-row flex-wrap space-y-1">
<Button className="mr-1" onClick={handleDirectMessage}>
<MessageSquareIcon className="mr-2" />
Message
</Button>
<Button className="mr-1" onClick={handleTraceroute}>
<WaypointsIcon className="mr-2" />
Trace Route
</Button>
<Button className="mr-1" onClick={handleToggleFavorite}>
<StarIcon
className={cn(
isFavoriteState ? " fill-yellow-400 stroke-yellow-400" : "",
)}
/>
</Button>
<div className="flex flex-1 justify-start"></div>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<Button
className={cn(
"flex justify-end mr-1 text-white",
isIgnoredState
? "bg-red-500 dark:bg-red-500 hover:bg-red-600 hover:dark:bg-red-600 text-white dark:text-white"
: "",
)}
onClick={handleToggleIgnored}
>
{isIgnoredState ? <BellIcon /> : <BellOffIcon />}
</Button>
</TooltipTrigger>
<TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs">
{isIgnoredState ? "Unignore node" : "Ignore node"}
<TooltipArrow className="fill-slate-800 dark:fill-slate-600" />
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="destructive"
className="flex justify-end"
onClick={handleNodeRemove}
>
<TrashIcon />
</Button>
</TooltipTrigger>
<TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs">
Remove node
<TooltipArrow className="fill-slate-800 dark:fill-slate-600" />
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<Separator className="mt-5 mb-2" />
<div className="flex flex-col flex-wrap space-x-1 space-y-1">
<div className="flex flex-row space-x-2">
<div className="w-full bg-slate-100 text-slate-900 dark:text-slate-100 dark:bg-slate-800 p-3 rounded-lg">
<p className="text-lg font-semibold">Details:</p>
<p>Node Number: {node.num}</p>
<p>Node Hex: !{numberToHexUnpadded(node.num)}</p>
<p>
Role: {Protobuf.Config.Config_DeviceConfig_Role[
node.user?.role ?? 0
].replace(/_/g, " ")}
</p>
<p>
Last Heard: {node.lastHeard === 0
? "Never"
: <TimeAgo timestamp={node.lastHeard * 1000} />}
</p>
<p>
Hardware:{" "}
{(Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0] ??
"Unknown")
.replace(/_/g, " ")}
</p>
</div>
<DeviceImage
className="h-45 w-45 p-2 rounded-lg border-4 border-slate-200 dark:border-slate-800"
deviceType={Protobuf.Mesh
.HardwareModel[node.user?.hwModel ?? 0]}
/>
</div>
</div>
<div>
<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>
{node.position
? (
<>
{node.position.latitudeI &&
node.position.longitudeI && (
<p>
Coordinates:{" "}
<a
className="text-blue-500 dark:text-blue-400"
href={`https://www.openstreetmap.org/?mlat=${
node.position.latitudeI / 1e7
}&mlon=${node.position.longitudeI / 1e7}&layers=N`}
target="_blank"
rel="noreferrer"
>
{node.position.latitudeI / 1e7},{" "}
{node.position.longitudeI / 1e7}
</a>
</p>
)}
{node.position.altitude && (
<p>Altitude: {node.position.altitude}m</p>
)}
</>
)
: <p>Unknown</p>}
<Button onClick={handleRequestPosition} className="mt-2">
<MapPinnedIcon className="mr-2" />
Request Position
</Button>
</div>
{device.position && (
{node.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">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">
<p className="text-lg font-semibold text-slate-900 dark:text-slate-100">
Device Metrics:
</p>
{deviceMetricsMap.map(
@@ -140,17 +314,16 @@ export const NodeDetailsDialog = ({
<p key={metric.key}>
{metric.label}: {metric.format(metric.value)}
</p>
)
),
)}
{device.deviceMetrics.uptimeSeconds && (
{node.deviceMetrics.uptimeSeconds && (
<p>
Uptime:{" "}
<Uptime seconds={device.deviceMetrics.uptimeSeconds} />
<Uptime seconds={node.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">
@@ -163,7 +336,7 @@ export const NodeDetailsDialog = ({
</AccordionTrigger>
<AccordionContent className="overflow-x-scroll">
<pre className="text-xs w-full">
{JSON.stringify(device, null, 2)}
{JSON.stringify(node, null, 2)}
</pre>
</AccordionContent>
</AccordionItem>

View File

@@ -1,117 +0,0 @@
import { toast } from "../../core/hooks/useToast.ts";
import { useAppStore } from "../../core/stores/appStore.ts";
import { useDevice } from "../../core/stores/deviceStore.ts";
import {
Dialog,
DialogClose,
DialogContent,
DialogHeader,
DialogTitle,
} from "../UI/Dialog.tsx";
import type { Protobuf } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { TrashIcon } from "lucide-react";
import { Button } from "../UI/Button.tsx";
export interface NodeOptionsDialogProps {
node: Protobuf.Mesh.NodeInfo | undefined;
open: boolean;
onOpenChange: () => void;
}
export const NodeOptionsDialog = ({
node,
open,
onOpenChange,
}: NodeOptionsDialogProps) => {
const { setDialogOpen, connection, setActivePage } = useDevice();
const {
setNodeNumToBeRemoved,
setNodeNumDetails,
setChatType,
setActiveChat,
} = useAppStore();
const longName = node?.user?.longName ??
(node ? `!${numberToHexUnpadded(node?.num)}` : "Unknown");
const shortName = node?.user?.shortName ??
(node ? `${numberToHexUnpadded(node?.num).substring(0, 4)}` : "UNK");
function handleDirectMessage() {
if (!node) return;
setChatType("direct");
setActiveChat(node.num);
setActivePage("messages");
}
function handleRequestPosition() {
if (!node) return;
toast({
title: "Requesting position, please wait...",
});
connection?.requestPosition(node.num).then(() =>
toast({
title: "Position request sent.",
})
);
onOpenChange();
}
function handleTraceroute() {
if (!node) return;
toast({
title: "Sending Traceroute, please wait...",
});
connection?.traceRoute(node.num).then(() =>
toast({
title: "Traceroute sent.",
})
);
onOpenChange();
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>{`${longName} (${shortName})`}</DialogTitle>
</DialogHeader>
<div className="flex flex-col space-y-1">
<div>
<Button onClick={handleDirectMessage}>Direct Message</Button>
</div>
<div>
<Button onClick={handleRequestPosition}>Request Position</Button>
</div>
<div>
<Button onClick={handleTraceroute}>Trace Route</Button>
</div>
<div>
<Button
key="remove"
variant="destructive"
onClick={() => {
setNodeNumToBeRemoved(node.num);
setDialogOpen("nodeRemoval", true);
}}
>
<TrashIcon />
Remove
</Button>
</div>
<div>
<Button
onClick={() => {
setNodeNumDetails(node.num);
setDialogOpen("nodeDetails", true);
}}
>
More Details
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -10,12 +10,22 @@ import {
} from "@components/UI/Dialog.tsx";
export interface PkiRegenerateDialogProps {
text: {
title: string;
description: string;
button: string;
};
open: boolean;
onOpenChange: () => void;
onSubmit: () => void;
}
export const PkiRegenerateDialog = ({
text = {
title: "Regenerate Key Pair",
description: "Are you sure you want to regenerate key pair?",
button: "Regenerate",
},
open,
onOpenChange,
onSubmit,
@@ -25,14 +35,14 @@ export const PkiRegenerateDialog = ({
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>Regenerate Key pair?</DialogTitle>
<DialogTitle>{text?.title}</DialogTitle>
<DialogDescription>
Are you sure you want to regenerate key pair?
{text?.description}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="destructive" onClick={() => onSubmit()}>
Regenerate
{text?.button}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -79,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}
@@ -108,20 +108,22 @@ 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
@@ -133,8 +135,8 @@ export const QRDialog = ({
<Input
value={qrCodeUrl}
disabled
className="dark:text-slate-900"
action={{
key: "copy-value",
icon: ClipboardIcon,
onClick() {
void navigator.clipboard.writeText(qrCodeUrl);

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { RebootOTADialog } from './RebootOTADialog.tsx';
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { RebootOTADialog } from "./RebootOTADialog.tsx";
import { ReactNode } from "react";
const rebootOtaMock = vi.fn();
@@ -8,41 +8,46 @@ let mockConnection: { rebootOta: (delay: number) => void } | undefined = {
rebootOta: rebootOtaMock,
};
vi.mock('@core/stores/deviceStore.ts', () => ({
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');
vi.mock("@components/UI/Button.tsx", async () => {
const actual = await vi.importActual("@components/UI/Button.tsx");
return {
...actual,
Button: (props: any) => <button {...props} />,
Button: (props) => <button {...props} />,
};
});
vi.mock('@components/UI/Input.tsx', async () => {
const actual = await vi.importActual('@components/UI/Input.tsx');
vi.mock("@components/UI/Input.tsx", async () => {
const actual = await vi.importActual("@components/UI/Input.tsx");
return {
...actual,
Input: (props: any) => <input {...props} />,
Input: (props) => <input {...props} />,
};
});
vi.mock('@components/UI/Dialog.tsx', () => {
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>,
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>,
DialogDescription: ({ children }: { children: ReactNode }) => (
<p>{children}</p>
),
DialogClose: () => null,
};
});
describe('RebootOTADialog', () => {
describe("RebootOTADialog", () => {
beforeEach(() => {
vi.useFakeTimers();
rebootOtaMock.mockClear();
@@ -52,19 +57,19 @@ describe('RebootOTADialog', () => {
vi.useRealTimers();
});
it('renders dialog with default input value', () => {
render(<RebootOTADialog open={true} onOpenChange={() => { }} />);
it("renders dialog with default input value", () => {
render(<RebootOTADialog open 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 () => {
it("schedules a reboot with delay and calls rebootOta", async () => {
const onOpenChangeMock = vi.fn();
render(<RebootOTADialog open={true} onOpenChange={onOpenChangeMock} />);
render(<RebootOTADialog open onOpenChange={onOpenChangeMock} />);
fireEvent.change(screen.getByPlaceholderText(/enter delay/i), {
target: { value: '3' },
target: { value: "3" },
});
fireEvent.click(screen.getByText(/schedule reboot/i));
@@ -79,9 +84,9 @@ describe('RebootOTADialog', () => {
});
});
it('triggers an instant reboot', async () => {
it("triggers an instant reboot", async () => {
const onOpenChangeMock = vi.fn();
render(<RebootOTADialog open={true} onOpenChange={onOpenChangeMock} />);
render(<RebootOTADialog open onOpenChange={onOpenChangeMock} />);
fireEvent.click(screen.getByText(/reboot to ota mode now/i));
@@ -91,13 +96,13 @@ describe('RebootOTADialog', () => {
});
});
it('does not call reboot if connection is undefined', async () => {
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} />);
render(<RebootOTADialog open onOpenChange={onOpenChangeMock} />);
fireEvent.click(screen.getByText(/schedule reboot/i));
vi.advanceTimersByTime(5000);
@@ -110,5 +115,4 @@ describe('RebootOTADialog', () => {
// reset connection for other tests
mockConnection = { rebootOta: rebootOtaMock };
});
});

View File

@@ -19,7 +19,9 @@ export interface RebootOTADialogProps {
const DEFAULT_REBOOT_DELAY = 5; // seconds
export const RebootOTADialog = ({ open, onOpenChange }: RebootOTADialogProps) => {
export const RebootOTADialog = (
{ open, onOpenChange }: RebootOTADialogProps,
) => {
const { connection } = useDevice();
const [time, setTime] = useState<number>(DEFAULT_REBOOT_DELAY);
const [isScheduled, setIsScheduled] = useState(false);
@@ -28,8 +30,8 @@ export const RebootOTADialog = ({ open, onOpenChange }: RebootOTADialogProps) =>
const handleSetTime = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.validity.valid) {
e.preventDefault();
return
};
return;
}
const val = e.target.value;
setInputValue(val);
@@ -73,7 +75,8 @@ export const RebootOTADialog = ({ open, onOpenChange }: RebootOTADialogProps) =>
<DialogHeader>
<DialogTitle>Reboot to OTA Mode</DialogTitle>
<DialogDescription>
Reboot the connected node after a delay into OTA (Over-the-Air) mode.
Reboot the connected node after a delay into OTA (Over-the-Air)
mode.
</DialogDescription>
</DialogHeader>
@@ -89,7 +92,7 @@ export const RebootOTADialog = ({ open, onOpenChange }: RebootOTADialogProps) =>
/>
<Button onClick={() => handleRebootWithTimeout()} className="w-9/12">
<ClockIcon className="mr-2" size={18} />
{isScheduled ? 'Reboot has been scheduled' : 'Schedule Reboot'}
{isScheduled ? "Reboot has been scheduled" : "Schedule Reboot"}
</Button>
</div>
@@ -101,4 +104,3 @@ export const RebootOTADialog = ({ open, onOpenChange }: RebootOTADialogProps) =>
</Dialog>
);
};

View File

@@ -1,55 +1,49 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { beforeEach, describe, expect, it, Mock, vi } from "vitest";
import { RefreshKeysDialog } from "./RefreshKeysDialog";
import { render } from "@testing-library/react";
import { DeviceContext, useDeviceStore } from "@core/stores/deviceStore.ts";
import { RefreshKeysDialog } from "./RefreshKeysDialog.tsx";
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
vi.mock("./useRefreshKeysDialog.ts", () => ({
useRefreshKeysDialog: vi.fn(),
}));
vi.mock("@core/stores/messageStore");
vi.mock("./useRefreshKeysDialog");
describe("RefreshKeysDialog Component", () => {
let handleCloseDialogMock: Mock;
let handleNodeRemoveMock: Mock;
let onOpenChangeMock: Mock;
const mockUseMessageStore = vi.mocked(useMessageStore);
const mockUseRefreshKeysDialog = vi.mocked(useRefreshKeysDialog);
beforeEach(() => {
handleCloseDialogMock = vi.fn();
handleNodeRemoveMock = vi.fn();
onOpenChangeMock = vi.fn();
const getInitialState = () =>
useDeviceStore.getInitialState?.() ??
{ devices: new Map(), remoteDevices: new Map() };
(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();
});
beforeEach(() => {
useDeviceStore.setState(getInitialState(), true);
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
test("does not render dialog if no error exists for active chat", () => {
const deviceId = 1;
const activeChatNum = 54321;
useDeviceStore.getState().addDevice(deviceId);
const currentDeviceState = useDeviceStore.getState().getDevice(deviceId);
if (!currentDeviceState) throw new Error("Device not found");
mockUseMessageStore.mockReturnValue({ activeChat: activeChatNum });
mockUseRefreshKeysDialog.mockReturnValue({
handleCloseDialog: vi.fn(),
handleNodeRemove: vi.fn(),
});
const { container } = render(
<DeviceContext.Provider value={currentDeviceState}>
<RefreshKeysDialog open onOpenChange={vi.fn()} />
</DeviceContext.Provider>,
);
expect(container.firstChild).toBeNull();
});

View File

@@ -8,27 +8,55 @@ import {
import { Button } from "@components/UI/Button.tsx";
import { LockKeyholeOpenIcon } from "lucide-react";
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
export interface RefreshKeysDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const RefreshKeysDialog = ({ open, onOpenChange }: RefreshKeysDialogProps) => {
export const RefreshKeysDialog = (
{ open, onOpenChange }: RefreshKeysDialogProps,
) => {
const { activeChat } = useMessageStore();
const { nodeErrors, getNode } = useDevice();
const { handleCloseDialog, handleNodeRemove } = useRefreshKeysDialog();
const nodeErrorNum = nodeErrors.get(activeChat);
if (!nodeErrorNum) {
return null;
}
const nodeWithError = getNode(nodeErrorNum.node);
const text = {
title: `Keys Mismatch - ${nodeWithError?.user?.longName ?? ""}`,
description: `Your node is unable to send a direct message to node: ${
nodeWithError?.user?.longName ?? ""
} (${
nodeWithError?.user?.shortName ?? ""
}). This is due to the remote node's current public key does not match the previously stored key for this node.`,
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-8 flex flex-col gap-2">
<DialogContent
className="max-w-8 flex flex-col gap-2"
aria-describedby={undefined}
>
<DialogClose onClick={handleCloseDialog} />
<DialogHeader>
<DialogTitle>Keys Mismatch</DialogTitle>
<DialogTitle>{text.title}</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.
{text.description}
<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" />
<LockKeyholeOpenIcon
size={30}
className="text-white justify-center"
/>
</div>
<div className="flex flex-col gap-2">
<div>
@@ -40,22 +68,19 @@ export const RefreshKeysDialog = ({ open, onOpenChange }: RefreshKeysDialogProps
<Button
variant="default"
onClick={handleNodeRemove}
className=""
>
Request New Keys
</Button>
<Button
variant="outline"
onClick={handleCloseDialog}
className=""
>
Dismiss
</Button>
</div>
</li>
</ul>
{/* </DialogDescription> */}
</DialogContent>
</Dialog >
</Dialog>
);
};

View File

@@ -1,13 +1,13 @@
import { renderHook, act } from "@testing-library/react";
import { act, renderHook } from "@testing-library/react";
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
import { beforeEach, describe, expect, it, Mock, vi } from "vitest";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useMessageStore } from "@core/stores/messageStore/index.ts";
vi.mock("@core/stores/appStore.ts", () => ({
useAppStore: vi.fn(() => ({ activeChat: "chat-123" })),
vi.mock("@core/stores/messageStore", () => ({
useMessageStore: vi.fn(() => ({ activeChat: "chat-123" })),
}));
vi.mock("@core/stores/deviceStore.ts", () => ({
vi.mock("@core/stores/deviceStore", () => ({
useDevice: vi.fn(() => ({
removeNode: vi.fn(),
setDialogOpen: vi.fn(),
@@ -23,46 +23,54 @@ describe("useRefreshKeysDialog Hook", () => {
let clearNodeErrorMock: Mock;
beforeEach(() => {
vi.clearAllMocks();
removeNodeMock = vi.fn();
setDialogOpenMock = vi.fn();
getNodeErrorMock = vi.fn();
getNodeErrorMock = vi.fn().mockReturnValue(undefined);
clearNodeErrorMock = vi.fn();
(useDevice as Mock).mockReturnValue({
vi.mocked(useDevice).mockReturnValue({
removeNode: removeNodeMock,
setDialogOpen: setDialogOpenMock,
getNodeError: getNodeErrorMock,
clearNodeError: clearNodeErrorMock,
});
vi.mocked(useMessageStore).mockReturnValue({
activeChat: "chat-123",
});
});
it("handleNodeRemove should remove the node and update dialog if there is an error", () => {
getNodeErrorMock.mockReturnValue({ node: "node-abc" });
const { result } = renderHook(() => useRefreshKeysDialog());
act(() => {
result.current.handleNodeRemove();
});
expect(getNodeErrorMock).toHaveBeenCalledTimes(1);
expect(getNodeErrorMock).toHaveBeenCalledWith("chat-123");
expect(clearNodeErrorMock).toHaveBeenCalledTimes(1);
expect(clearNodeErrorMock).toHaveBeenCalledWith("chat-123");
expect(removeNodeMock).toHaveBeenCalledTimes(1);
expect(removeNodeMock).toHaveBeenCalledWith("node-abc");
expect(setDialogOpenMock).toHaveBeenCalledTimes(1);
expect(setDialogOpenMock).toHaveBeenCalledWith("refreshKeys", false);
});
it("handleNodeRemove should do nothing if there is no error", () => {
getNodeErrorMock.mockReturnValue(undefined);
const { result } = renderHook(() => useRefreshKeysDialog());
act(() => {
result.current.handleNodeRemove();
});
expect(getNodeErrorMock).toHaveBeenCalledTimes(1);
expect(getNodeErrorMock).toHaveBeenCalledWith("chat-123");
expect(clearNodeErrorMock).not.toHaveBeenCalled();
expect(removeNodeMock).not.toHaveBeenCalled();
expect(setDialogOpenMock).not.toHaveBeenCalled();
expect(clearNodeErrorMock).not.toHaveBeenCalled();
});
it("handleCloseDialog should close the dialog", () => {
@@ -72,6 +80,7 @@ describe("useRefreshKeysDialog Hook", () => {
result.current.handleCloseDialog();
});
expect(setDialogOpenMock).toHaveBeenCalledTimes(1);
expect(setDialogOpenMock).toHaveBeenCalledWith("refreshKeys", false);
});
});

View File

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

View File

@@ -21,7 +21,7 @@ export const RemoveNodeDialog = ({
open,
onOpenChange,
}: RemoveNodeDialogProps) => {
const { connection, nodes, removeNode } = useDevice();
const { connection, getNode, removeNode } = useDevice();
const { nodeNumToBeRemoved } = useAppStore();
const onSubmit = () => {
@@ -42,7 +42,7 @@ export const RemoveNodeDialog = ({
</DialogHeader>
<div className="gap-4">
<form onSubmit={onSubmit}>
<Label>{nodes.get(nodeNumToBeRemoved)?.user?.longName}</Label>
<Label>{getNode(nodeNumToBeRemoved)?.user?.longName}</Label>
</form>
</div>
<DialogFooter>

View File

@@ -41,7 +41,6 @@ export const ShutdownDialog = ({
type="number"
value={time}
onChange={(e) => setTime(Number.parseInt(e.target.value))}
className="dark:text-slate-900"
suffix="Minutes"
/>
<Button

View File

@@ -23,17 +23,17 @@ export const TracerouteResponseDialog = ({
open,
onOpenChange,
}: TracerouteResponseDialogProps) => {
const { nodes } = useDevice();
const { getNode } = useDevice();
const route: number[] = traceroute?.data.route ?? [];
const routeBack: number[] = traceroute?.data.routeBack ?? [];
const snrTowards = traceroute?.data.snrTowards ?? [];
const snrBack = traceroute?.data.snrBack ?? [];
const from = nodes.get(traceroute?.from ?? 0);
const snrTowards = (traceroute?.data.snrTowards ?? []).map((snr) => snr / 4);
const snrBack = (traceroute?.data.snrBack ?? []).map((snr) => snr / 4);
const from = getNode(traceroute?.from ?? 0);
const longName = from?.user?.longName ??
(from ? `!${numberToHexUnpadded(from?.num)}` : "Unknown");
const shortName = from?.user?.shortName ??
(from ? `${numberToHexUnpadded(from?.num).substring(0, 4)}` : "UNK");
const to = nodes.get(traceroute?.to ?? 0);
const to = getNode(traceroute?.to ?? 0);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>

View File

@@ -1,6 +1,6 @@
// deno-lint-ignore-file
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx";
import { eventBus } from "@core/utils/eventBus.ts";
import { DeviceWrapper } from "@app/DeviceWrapper.tsx";
@@ -10,41 +10,58 @@ describe("UnsafeRolesDialog", () => {
setDialogOpen: vi.fn(),
};
const renderWithDeviceContext = (ui: any) => {
const renderWithDeviceContext = (ui: React.ReactNode) => {
return render(
<DeviceWrapper device={mockDevice}>
{ui}
</DeviceWrapper>
</DeviceWrapper>,
);
};
it("renders the dialog when open is true", () => {
renderWithDeviceContext(<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />);
renderWithDeviceContext(
<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />,
);
const dialog = screen.getByRole('dialog');
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();
expect(screen.getByText(/understand the implications/i))
.toBeInTheDocument();
const links = screen.getAllByRole('link');
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');
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()} />);
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 });
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/");
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()} />);
renderWithDeviceContext(
<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />,
);
const confirmButton = screen.getByRole("button", { name: /confirm/i });
@@ -58,27 +75,37 @@ describe("UnsafeRolesDialog", () => {
it("emits the correct event when closing via close button", () => {
const eventSpy = vi.spyOn(eventBus, "emit");
renderWithDeviceContext(<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />);
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" });
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()} />);
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" });
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()} />);
renderWithDeviceContext(
<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />,
);
const checkbox = screen.getByRole("checkbox");
const confirmButton = screen.getByRole("button", { name: /confirm/i });
@@ -86,6 +113,8 @@ describe("UnsafeRolesDialog", () => {
fireEvent.click(checkbox);
fireEvent.click(confirmButton);
expect(eventSpy).toHaveBeenCalledWith("dialog:unsafeRoles", { action: "confirm" });
expect(eventSpy).toHaveBeenCalledWith("dialog:unsafeRoles", {
action: "confirm",
});
});
});

View File

@@ -19,29 +19,40 @@ export interface RouterRoleDialogProps {
onOpenChange: (open: boolean) => void;
}
export const UnsafeRolesDialog = ({ open, onOpenChange }: RouterRoleDialogProps) => {
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 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);
const handleCloseDialog = (action: "confirm" | "dismiss") => {
setDialogOpen("unsafeRoles", false);
setConfirmState(false);
eventBus.emit('dialog:unsafeRoles', { action });
}
eventBus.emit("dialog:unsafeRoles", { action });
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-8 flex flex-col">
<DialogClose onClick={() => handleCloseDialog('dismiss')} />
<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.
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
@@ -56,16 +67,20 @@ export const UnsafeRolesDialog = ({ open, onOpenChange }: RouterRoleDialogProps)
<Button
variant="default"
name="dismiss"
onClick={() => handleCloseDialog('dismiss')}> Dismiss
onClick={() => handleCloseDialog("dismiss")}
>
Dismiss
</Button>
<Button
variant="default"
name="confirm"
disabled={!confirmState}
onClick={() => handleCloseDialog('confirm')}> Confirm
onClick={() => handleCloseDialog("confirm")}
>
Confirm
</Button>
</DialogFooter>
</DialogContent>
</Dialog >
</Dialog>
);
};

View File

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

View File

@@ -21,7 +21,9 @@ export const useUnsafeRolesDialog = () => {
setDialogOpen("unsafeRoles", true);
return new Promise((resolve) => {
const handleResponse = ({ action }: { action: "confirm" | "dismiss" }) => {
const handleResponse = (
{ action }: { action: "confirm" | "dismiss" },
) => {
eventBus.off("dialog:unsafeRoles", handleResponse);
resolve(action === "confirm");
};
@@ -29,7 +31,7 @@ export const useUnsafeRolesDialog = () => {
eventBus.on("dialog:unsafeRoles", handleResponse);
});
},
[setDialogOpen]
[setDialogOpen],
);
return {

View File

@@ -15,7 +15,6 @@ import {
} from "react-hook-form";
import { Heading } from "@components/UI/Typography/Heading.tsx";
interface DisabledBy<T> {
fieldName: Path<T>;
selector?: number;
@@ -124,7 +123,9 @@ export function DynamicForm<T extends FieldValues>({
})}
</div>
))}
{hasSubmitButton && <Button type="submit" variant="outline">Submit</Button>}
{hasSubmitButton && (
<Button type="submit" variant="outline">Submit</Button>
)}
</form>
);
}

View File

@@ -3,11 +3,9 @@ import type {
GenericFormElementProps,
} from "@components/Form/DynamicForm.tsx";
import { Input } from "@components/UI/Input.tsx";
import type { LucideIcon } from "lucide-react";
import { Eye, EyeOff } from "lucide-react";
import type { ChangeEventHandler } from "react";
import { useState } from "react";
import { useController, type FieldValues } from "react-hook-form";
import { type FieldValues, useController } from "react-hook-form";
export interface InputFieldProps<T> extends BaseFormBuilderProps<T> {
type: "text" | "number" | "password";
@@ -17,16 +15,15 @@ export interface InputFieldProps<T> extends BaseFormBuilderProps<T> {
prefix?: string;
suffix?: string;
step?: number;
className?: string;
fieldLength?: {
min?: number;
max?: number;
currentValueLength?: number;
showCharacterCount?: boolean;
},
action?: {
icon: LucideIcon;
onClick: () => void;
};
showPasswordToggle?: boolean;
showCopyButton?: boolean;
};
}
@@ -36,55 +33,55 @@ export function GenericInput<T extends FieldValues>({
field,
}: GenericFormElementProps<T, InputFieldProps<T>>) {
const { fieldLength, ...restProperties } = field.properties || {};
const [passwordShown, setPasswordShown] = useState(false);
const [currentLength, setCurrentLength] = useState<number>(fieldLength?.currentValueLength || 0);
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) {
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);
controllerField.onChange(
field.type === "number"
? Number.parseFloat(newValue).toString()
: newValue,
);
};
return (
<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
}
type={field.type}
step={field.properties?.step}
value={field.type === "number" ? String(controllerField.value) : controllerField.value}
value={field.type === "number"
? String(controllerField.value)
: controllerField.value}
id={field.name}
onChange={handleInputChange}
showCopyButton={field.properties?.showCopyButton}
showPasswordToggle={field.properties?.showPasswordToggle ||
field.type === "password"}
className={field.properties?.className}
{...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">
<div className="absolute inset-y-0 right-0 flex items-center pr-3 text-sm text-slate-900 dark:text-slate-200">
{currentLength ?? fieldLength?.currentValueLength}/{fieldLength?.max}
</div>
)}

View File

@@ -61,4 +61,4 @@ export function MultiSelectInput<T extends FieldValues>({
))}
</MultiSelect>
);
}
}

View File

@@ -4,10 +4,9 @@ import type {
} from "@components/Form/DynamicForm.tsx";
import type { ButtonVariant } from "../UI/Button.tsx";
import { Generator } from "@components/UI/Generator.tsx";
import { Eye, EyeOff } from "lucide-react";
import type { ChangeEventHandler } from "react";
import { useState } from "react";
import { Controller, type FieldValues } from "react-hook-form";
import { usePasswordVisibilityToggle } from "@core/hooks/usePasswordVisibilityToggle.ts";
export interface PasswordGeneratorProps<T> extends BaseFormBuilderProps<T> {
type: "passwordGenerator";
@@ -15,7 +14,7 @@ export interface PasswordGeneratorProps<T> extends BaseFormBuilderProps<T> {
hide?: boolean;
bits?: { text: string; value: string; key: string }[];
devicePSKBitCount: number;
inputChange: ChangeEventHandler;
inputChange: ChangeEventHandler<HTMLInputElement> | undefined;
selectChange: (event: string) => void;
actionButtons: {
text: string;
@@ -23,6 +22,8 @@ export interface PasswordGeneratorProps<T> extends BaseFormBuilderProps<T> {
variant: ButtonVariant;
className?: string;
}[];
showPasswordToggle?: boolean;
showCopyButton?: boolean;
}
export function PasswordGenerator<T extends FieldValues>({
@@ -30,10 +31,7 @@ export function PasswordGenerator<T extends FieldValues>({
field,
disabled,
}: GenericFormElementProps<T, PasswordGeneratorProps<T>>) {
const [passwordShown, setPasswordShown] = useState(false);
const togglePasswordVisiblity = () => {
setPasswordShown(!passwordShown);
};
const { isVisible } = usePasswordVisibilityToggle();
return (
<Controller
@@ -41,14 +39,8 @@ export function PasswordGenerator<T extends FieldValues>({
control={control}
render={({ field: { value, ...rest } }) => (
<Generator
type={field.hide && !passwordShown ? "password" : "text"}
type={field.hide && !isVisible ? "password" : "text"}
id={field.id}
action={field.hide
? {
icon: passwordShown ? EyeOff : Eye,
onClick: togglePasswordVisiblity,
}
: undefined}
devicePSKBitCount={field.devicePSKBitCount}
bits={field.bits}
inputChange={field.inputChange}
@@ -56,6 +48,8 @@ export function PasswordGenerator<T extends FieldValues>({
value={value}
variant={field.validationText ? "invalid" : "default"}
actionButtons={field.actionButtons}
showPasswordToggle={field.showPasswordToggle}
showCopyButton={field.showCopyButton}
{...field.properties}
{...rest}
disabled={disabled}

View File

@@ -9,7 +9,7 @@ import {
SelectTrigger,
SelectValue,
} from "@components/UI/Select.tsx";
import { useController, type FieldValues } from "react-hook-form";
import { type FieldValues, useController } from "react-hook-form";
export interface SelectFieldProps<T> extends BaseFormBuilderProps<T> {
type: "select";
@@ -46,7 +46,8 @@ export function SelectInput<T extends FieldValues>({
control,
});
const { enumValue, formatEnumName, ...remainingProperties } = field.properties;
const { enumValue, formatEnumName, ...remainingProperties } =
field.properties;
const valueToKeyMap: Record<string, string> = {};
const optionsEnumValues: [string, number][] = [];

View File

@@ -20,17 +20,18 @@ export const FieldWrapper = ({
}: FieldWrapperProps) => (
<div className="pt-6 sm:pt-5">
<fieldset aria-labelledby="label-notifications">
<div className="sm:grid sm:grid-cols-3 sm:items-baseline sm:gap-4">
{/* first column = labels/heading, second column = fields, third column = gutter */}
<div className="grid grid-cols-1 lg:grid-cols-[0.6fr_2fr_.1fr] sm:items-baseline gap-4">
<Label htmlFor={fieldName}>{label}</Label>
<div className="sm:col-span-2">
<div className="max-w-lg">
<p className="text-sm text-slate-500">{description}</p>
<p hidden={valid ?? true} className="text-sm text-red-500">
{validationText}
</p>
<div className="mt-4 space-y-4">
<div className="flex items-center">{children}</div>
</div>
<div className="max-w-3xl">
<p className="text-sm text-slate-500 dark:text-slate-400">
{description}
</p>
<p hidden={valid ?? true} className="text-sm text-red-500">
{validationText}
</p>
<div className="mt-4 space-y-4 sm:col-span-2">
<div className="flex items-center">{children}</div>
</div>
</div>
</div>

View File

@@ -1,4 +1,4 @@
import type { ChannelValidation } from "@app/validation/channel.tsx";
import type { ChannelValidation } from "@app/validation/channel.ts";
import { create } from "@bufbuild/protobuf";
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import { useToast } from "@core/hooks/useToast.ts";
@@ -97,9 +97,14 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
settings: {
...channel?.settings,
psk: pass,
moduleSettings: {...channel?.settings?.moduleSettings,
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,
},
},
},
}}
@@ -124,7 +129,7 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
{
type: "passwordGenerator",
name: "settings.psk",
id: 'channel-psk',
id: "channel-psk",
label: "Pre-Shared Key",
description:
"Supported PSK lengths: 256-bit, 128-bit, 8-bit, Empty (0-bit)",
@@ -142,6 +147,8 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
hide: true,
properties: {
value: pass,
showPasswordToggle: true,
showCopyButton: true,
},
},
{
@@ -206,6 +213,12 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
]}
/>
<PkiRegenerateDialog
text={{
button: "Regenerate",
title: "Regenerate Pre-Shared Key?",
description:
"Are you sure you want to regenerate the pre-shared key?",
}}
open={preSharedDialogOpen}
onOpenChange={() => setPreSharedDialogOpen(false)}
onSubmit={() => preSharedKeyRegenerate()}

View File

@@ -1,5 +1,5 @@
import { useAppStore } from "../../../core/stores/appStore.ts";
import type { BluetoothValidation } from "@app/validation/config/bluetooth.tsx";
import type { BluetoothValidation } from "@app/validation/config/bluetooth.ts";
import { create } from "@bufbuild/protobuf";
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";

View File

@@ -1,21 +1,21 @@
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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { Device } from "@components/PageComponents/Config/Device/index.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useUnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
import { Protobuf } from "@meshtastic/core";
vi.mock('@core/stores/deviceStore', () => ({
useDevice: vi.fn()
vi.mock("@core/stores/deviceStore.ts", () => ({
useDevice: vi.fn(),
}));
vi.mock('@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog', () => ({
useUnsafeRolesDialog: vi.fn()
vi.mock("@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts", () => ({
useUnsafeRolesDialog: vi.fn(),
}));
// Mock the DynamicForm component since we're testing the Device component,
// not the DynamicForm implementation
vi.mock('@components/Form/DynamicForm', () => ({
vi.mock("@components/Form/DynamicForm", () => ({
DynamicForm: vi.fn(({ onSubmit }) => {
// Render a simplified version of the form for testing
return (
@@ -28,13 +28,16 @@ vi.mock('@components/Form/DynamicForm', () => ({
onSubmit(mockData);
}}
>
{Object.entries(Protobuf.Config.Config_DeviceConfig_Role).map(([key, value]) => (
{Object.entries(Protobuf.Config.Config_DeviceConfig_Role).map((
[key, value],
) => (
<option key={key} value={value}>
{key}
</option>
))}
</select>
<button type="submit"
<button
type="submit"
data-testid="submit-button"
onClick={() => onSubmit({ role: "CLIENT" })}
>
@@ -42,10 +45,10 @@ vi.mock('@components/Form/DynamicForm', () => ({
</button>
</div>
);
})
}),
}));
describe('Device component', () => {
describe("Device component", () => {
const setWorkingConfigMock = vi.fn();
const validateRoleSelectionMock = vi.fn();
const mockDeviceConfig = {
@@ -63,17 +66,17 @@ describe('Device component', () => {
vi.resetAllMocks();
// Mock the useDevice hook
(useDevice as any).mockReturnValue({
useDevice.mockReturnValue({
config: {
device: mockDeviceConfig
device: mockDeviceConfig,
},
setWorkingConfig: setWorkingConfigMock
setWorkingConfig: setWorkingConfigMock,
});
// Mock the useUnsafeRolesDialog hook
validateRoleSelectionMock.mockResolvedValue(true);
(useUnsafeRolesDialog as any).mockReturnValue({
validateRoleSelection: validateRoleSelectionMock
useUnsafeRolesDialog.mockReturnValue({
validateRoleSelection: validateRoleSelectionMock,
});
});
@@ -81,49 +84,48 @@ describe('Device component', () => {
vi.clearAllMocks();
});
it('should render the Device form', () => {
it("should render the Device form", () => {
render(<Device />);
expect(screen.getByTestId('dynamic-form')).toBeInTheDocument();
expect(screen.getByTestId("dynamic-form")).toBeInTheDocument();
});
it('should use the validateRoleSelection from the unsafe roles hook', () => {
it("should use the validateRoleSelection from the unsafe roles hook", () => {
render(<Device />);
expect(useUnsafeRolesDialog).toHaveBeenCalled();
});
it('should call setWorkingConfig when form is submitted', async () => {
it("should call setWorkingConfig when form is submitted", async () => {
render(<Device />);
fireEvent.click(screen.getByTestId('submit-button'));
fireEvent.click(screen.getByTestId("submit-button"));
await waitFor(() => {
expect(setWorkingConfigMock).toHaveBeenCalledWith(
expect.objectContaining({
payloadVariant: {
case: "device",
value: expect.objectContaining({ role: "CLIENT" })
}
})
value: expect.objectContaining({ role: "CLIENT" }),
},
}),
);
});
});
it('should create config with proper structure', async () => {
it("should create config with proper structure", async () => {
render(<Device />);
// Simulate form submission
fireEvent.click(screen.getByTestId('submit-button'));
fireEvent.click(screen.getByTestId("submit-button"));
await waitFor(() => {
expect(setWorkingConfigMock).toHaveBeenCalledWith(
expect.objectContaining({
payloadVariant: {
case: "device",
value: expect.any(Object)
}
})
value: expect.any(Object),
},
}),
);
});
});
});
});

View File

@@ -16,7 +16,7 @@ export const Device = () => {
case: "device",
value: data,
},
})
}),
);
};
return (
@@ -83,16 +83,16 @@ export const Device = () => {
description: "Disable triple click",
},
{
type: 'text',
name: 'tzdef',
label: 'POSIX Timezone',
description: 'The POSIX timezone string for the device',
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,
}
},
},
},
{

View File

@@ -61,6 +61,12 @@ export const Display = () => {
label: "Compass North Top",
description: "Fix north to the top of compass",
},
{
type: "toggle",
name: "use12hClock",
label: "12-Hour Clock",
description: "Use 12-hour clock format",
},
{
type: "toggle",
name: "flipScreen",

View File

@@ -1,131 +1,24 @@
// 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { Network } from "@components/PageComponents/Config/Network/index.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core";
vi.mock('@core/stores/deviceStore', () => ({
useDevice: vi.fn()
vi.mock("@core/stores/deviceStore", () => ({
useDevice: vi.fn(),
}));
vi.mock('@components/Form/DynamicForm', async () => {
const React = await import('react');
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 ?? '');
DynamicForm: ({ onSubmit, defaultValues }) => {
const [wifiEnabled, setWifiEnabled] = useState(
defaultValues.wifiEnabled ?? false,
);
const [ssid, setSsid] = useState(defaultValues.wifiSsid ?? "");
const [psk, setPsk] = useState(defaultValues.wifiPsk ?? "");
return (
<form
@@ -166,15 +59,14 @@ vi.mock('@components/Form/DynamicForm', async () => {
},
};
});
;
describe('Network component', () => {
describe("Network component", () => {
const setWorkingConfigMock = vi.fn();
const mockNetworkConfig = {
wifiEnabled: false,
wifiSsid: '',
wifiPsk: '',
ntpServer: '',
wifiSsid: "",
wifiPsk: "",
ntpServer: "",
ethEnabled: false,
addressMode: Protobuf.Config.Config_NetworkConfig_AddressMode.DHCP,
ipv4Config: {
@@ -185,17 +77,17 @@ describe('Network component', () => {
},
enabledProtocols:
Protobuf.Config.Config_NetworkConfig_ProtocolFlags.NO_BROADCAST,
rsyslogServer: '',
rsyslogServer: "",
};
beforeEach(() => {
vi.resetAllMocks();
(useDevice as any).mockReturnValue({
useDevice.mockReturnValue({
config: {
network: mockNetworkConfig
network: mockNetworkConfig,
},
setWorkingConfig: setWorkingConfigMock
setWorkingConfig: setWorkingConfigMock,
});
});
@@ -203,21 +95,21 @@ describe('Network component', () => {
vi.clearAllMocks();
});
it('should render the Network form', () => {
it("should render the Network form", () => {
render(<Network />);
expect(screen.getByTestId('dynamic-form')).toBeInTheDocument();
expect(screen.getByTestId("dynamic-form")).toBeInTheDocument();
});
it('should disable SSID and PSK fields when wifi is off', () => {
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 () => {
it("should enable SSID and PSK when wifi is toggled on", async () => {
render(<Network />);
const toggle = screen.getByLabelText("WiFi Enabled");
screen.debug()
screen.debug();
fireEvent.click(toggle); // turns wifiEnabled = true
@@ -227,7 +119,7 @@ describe('Network component', () => {
});
});
it('should call setWorkingConfig with the right structure on submit', async () => {
it("should call setWorkingConfig with the right structure on submit", async () => {
render(<Network />);
fireEvent.click(screen.getByTestId("submit-button"));
@@ -239,28 +131,28 @@ describe('Network component', () => {
case: "network",
value: expect.objectContaining({
wifiEnabled: false,
wifiSsid: '',
wifiPsk: '',
ntpServer: '',
wifiSsid: "",
wifiPsk: "",
ntpServer: "",
ethEnabled: false,
rsyslogServer: '',
})
}
})
rsyslogServer: "",
}),
},
}),
);
});
});
it('should submit valid data after enabling wifi and entering SSID and PSK', async () => {
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" }
target: { value: "MySSID" },
});
fireEvent.change(screen.getByLabelText("PSK"), {
target: { value: "MySecretPSK" }
target: { value: "MySecretPSK" },
});
fireEvent.click(screen.getByTestId("submit-button"));
@@ -273,10 +165,10 @@ describe('Network component', () => {
value: expect.objectContaining({
wifiEnabled: true,
wifiSsid: "MySSID",
wifiPsk: "MySecretPSK"
})
}
})
wifiPsk: "MySecretPSK",
}),
},
}),
);
});
});

View File

@@ -1,4 +1,7 @@
import { NetworkValidationSchema, type NetworkValidation } from "@app/validation/config/network.ts";
import {
type NetworkValidation,
NetworkValidationSchema,
} 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";
@@ -55,8 +58,8 @@ export const Network = () => {
),
dns: convertIntToIpAddress(config.network?.ipv4Config?.dns ?? 0),
},
enabledProtocols: config.network?.enabledProtocols ?? Protobuf.Config.Config_NetworkConfig_ProtocolFlags.NO_BROADCAST
enabledProtocols: config.network?.enabledProtocols ??
Protobuf.Config.Config_NetworkConfig_ProtocolFlags.NO_BROADCAST,
}}
fieldGroups={[
{
@@ -183,10 +186,9 @@ export const Network = () => {
name: "enabledProtocols",
label: "Mesh via UDP",
properties: {
enumValue:
Protobuf.Config.Config_NetworkConfig_ProtocolFlags,
enumValue: Protobuf.Config.Config_NetworkConfig_ProtocolFlags,
formatEnumName: true,
}
},
},
],
},

View File

@@ -1,16 +1,12 @@
import { PkiRegenerateDialog } from "@components/Dialog/PkiRegenerateDialog.tsx";
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import { useAppStore } from "@core/stores/appStore.ts";
import {
getX25519PrivateKey,
getX25519PublicKey,
} from "@core/utils/x25519.ts";
import { getX25519PrivateKey, getX25519PublicKey } from "@core/utils/x25519.ts";
import type { SecurityValidation } from "@app/validation/config/security.ts";
import { create } from "@bufbuild/protobuf";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core";
import { fromByteArray, toByteArray } from "base64-js";
import { Eye, EyeOff } from "lucide-react";
import { useReducer } from "react";
import { securityReducer } from "@components/PageComponents/Config/Security/securityReducer.tsx";
@@ -58,7 +54,8 @@ export const Security = () => {
if (input.length % 4 !== 0) {
addError(
fieldName,
`${fieldName === "privateKey" ? "Private" : "Admin"
`${
fieldName === "privateKey" ? "Private" : "Admin"
} Key is required to be a 256 bit pre-shared key (PSK)`,
);
return;
@@ -73,7 +70,8 @@ export const Security = () => {
console.error(e);
addError(
fieldName,
`Invalid ${fieldName === "privateKey" ? "Private" : "Admin"
`Invalid ${
fieldName === "privateKey" ? "Private" : "Admin"
} Key format`,
);
}
@@ -195,11 +193,8 @@ export const Security = () => {
],
properties: {
value: state.privateKey,
action: {
icon: state.privateKeyVisible ? EyeOff : Eye,
onClick: () =>
dispatch({ type: "TOGGLE_PRIVATE_KEY_VISIBILITY" }),
},
showCopyButton: true,
showPasswordToggle: true,
},
},
{
@@ -211,6 +206,7 @@ export const Security = () => {
"Sent out to other nodes on the mesh to allow them to compute a shared secret key",
properties: {
value: state.publicKey,
showCopyButton: true,
},
},
],
@@ -244,7 +240,7 @@ export const Security = () => {
? getErrorMessage("adminKey")
: "",
inputChange: adminKeyInputChangeEvent,
selectChange: () => { },
selectChange: () => {},
bits: [{ text: "256 bit", value: "32", key: "bit256" }],
devicePSKBitCount: state.privateKeyBitCount,
hide: !state.adminKeyVisible,
@@ -271,11 +267,7 @@ export const Security = () => {
],
properties: {
value: state.adminKey,
action: {
icon: state.adminKeyVisible ? EyeOff : Eye,
onClick: () =>
dispatch({ type: "TOGGLE_ADMIN_KEY_VISIBILITY" }),
},
showCopyButton: true,
},
},
],
@@ -302,6 +294,11 @@ export const Security = () => {
]}
/>
<PkiRegenerateDialog
text={{
button: "Regenerate",
title: "Regenerate Key pair?",
description: "Are you sure you want to regenerate key pair?",
}}
open={state.privateKeyDialogOpen}
onOpenChange={() =>
dispatch({ type: "SHOW_PRIVATE_KEY_DIALOG", payload: false })}

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