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

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

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

View File

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

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

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

View File

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

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

3
.gitignore vendored
View File

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

View File

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

View File

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

2058
bun.lock
View File

File diff suppressed because it is too large Load Diff

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"
]

2808
deno.lock generated
View File

File diff suppressed because it is too large Load Diff

2
infra/.dockerignore Normal file
View File

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

15
infra/Containerfile Normal file
View File

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

42
infra/default.conf Normal file
View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "meshtastic-web",
"version": "2.3.3-0",
"version": "2.6.0-0",
"type": "module",
"description": "Meshtastic web client",
"license": "GPL-3.0-only",
@@ -12,9 +12,7 @@
"format": "deno fmt src/",
"dev": "deno task dev:ui",
"dev:ui": "deno run -A npm:vite dev",
"dev:scan": "VITE_DEBUG_SCAN=true deno task dev:ui",
"test": "deno run -A npm:vitest",
"test:ui": "deno task test --ui",
"preview": "deno run -A npm:vite preview",
"package": "gzipper c -i html,js,css,png,ico,svg,webmanifest,txt dist dist/output && tar -cvf dist/build.tar -C ./dist/output/ ."
},
@@ -36,72 +34,78 @@
},
"homepage": "https://meshtastic.org",
"dependencies": {
"@bufbuild/protobuf": "^2.2.3",
"@meshtastic/core": "npm:@jsr/meshtastic__core@2.6.0-0",
"@meshtastic/core": "npm:@jsr/meshtastic__core@2.6.2",
"@meshtastic/js": "npm:@jsr/meshtastic__js@2.6.0-0",
"@meshtastic/transport-http": "npm:@jsr/meshtastic__transport-http",
"@meshtastic/transport-web-bluetooth": "npm:@jsr/meshtastic__transport-web-bluetooth",
"@meshtastic/transport-web-serial": "npm:@jsr/meshtastic__transport-web-serial",
"@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",
"@bufbuild/protobuf": "^2.2.5",
"@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.0.4",
"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.477.0",
"maplibre-gl": "5.1.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-error-boundary": "^5.0.0",
"react-hook-form": "^7.54.2",
"react-map-gl": "8.0.1",
"lucide-react": "^0.507.0",
"maplibre-gl": "5.4.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-error-boundary": "^6.0.0",
"react-hook-form": "^7.56.2",
"react-map-gl": "8.0.4",
"react-qrcode-logo": "^3.0.0",
"react-scan": "^0.2.8",
"rfc4648": "^1.5.4",
"vite-plugin-node-polyfills": "^0.23.0",
"zustand": "5.0.3"
"zod": "^3.24.3",
"zustand": "5.0.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.0.9",
"@testing-library/react": "^16.2.0",
"@types/chrome": "^0.0.307",
"@tailwindcss/postcss": "^4.1.5",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/chrome": "^0.0.318",
"@types/js-cookie": "^3.0.6",
"@types/node": "^22.13.7",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@types/serviceworker": "^0.0.123",
"@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",
"autoprefixer": "^10.4.20",
"gzipper": "^8.2.0",
"happy-dom": "^17.1.8",
"@vitejs/plugin-react": "^4.4.1",
"autoprefixer": "^10.4.21",
"gzipper": "^8.2.1",
"happy-dom": "^17.4.6",
"postcss": "^8.5.3",
"simple-git-hooks": "^2.11.1",
"tailwind-merge": "^3.0.2",
"tailwindcss": "^4.0.9",
"simple-git-hooks": "^2.13.0",
"tailwind-merge": "^3.2.0",
"tailwindcss": "^4.1.5",
"tailwindcss-animate": "^1.0.7",
"tar": "^7.4.3",
"typescript": "^5.8.2",
"vite": "^6.2.0",
"vite-plugin-pwa": "^0.21.1",
"vitest": "^3.0.7"
"testing-library": "^0.0.2",
"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,7 +1,5 @@
import { DeviceWrapper } from "@app/DeviceWrapper.tsx";
import { PageRouter } from "@app/PageRouter.tsx";
import { CommandPalette } from "@components/CommandPalette.tsx";
import { DeviceSelector } from "@components/DeviceSelector.tsx";
import { DialogManager } from "@components/Dialog/DialogManager.tsx";
import { NewDeviceDialog } from "@components/Dialog/NewDeviceDialog.tsx";
import { KeyBackupReminder } from "@components/KeyBackupReminder.tsx";
@@ -14,7 +12,9 @@ import type { JSX } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { ErrorPage } from "@components/UI/ErrorPage.tsx";
import { MapProvider } from "react-map-gl/maplibre";
import { CommandPalette } from "@components/CommandPalette/index.tsx";
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>

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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>,
}));

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

@@ -1,4 +1,3 @@
import { Avatar } from "./UI/Avatar.tsx";
import {
CommandDialog,
CommandEmpty,
@@ -21,6 +20,7 @@ import {
type LucideIcon,
MapIcon,
MessageSquareIcon,
Pin,
PlusIcon,
PowerIcon,
QrCodeIcon,
@@ -29,9 +29,11 @@ import {
SmartphoneIcon,
TrashIcon,
UsersIcon,
XCircleIcon,
} from "lucide-react";
import { useEffect } from "react";
import { Avatar } from "@components/UI/Avatar.tsx";
import { cn } from "@core/utils/cn.ts";
import { usePinnedItems } from "@core/hooks/usePinnedItems.ts";
export interface Group {
label: string;
@@ -45,7 +47,6 @@ export interface Command {
subItems?: SubItem[];
tags?: string[];
}
export interface SubItem {
label: string;
icon: React.ReactNode;
@@ -56,12 +57,14 @@ export const CommandPalette = () => {
const {
commandPaletteOpen,
setCommandPaletteOpen,
setConnectDialogOpen,
setSelectedDevice,
removeDevice,
selectedDevice,
} = useAppStore();
const { getDevices } = useDeviceStore();
const { setDialogOpen, setActivePage, connection } = useDevice();
const { setDialogOpen, setActivePage, getNode, connection } = useDevice();
const { pinnedItems, togglePinnedItem } = usePinnedItems({
storageName: "pinnedCommandMenuGroups",
});
const groups: Group[] = [
{
@@ -113,28 +116,25 @@ export const CommandPalette = () => {
{
label: "Switch Node",
icon: ArrowLeftRightIcon,
subItems: getDevices().map((device) => {
return {
label:
device.nodes.get(device.hardware.myNodeNum)?.user?.longName ??
device.hardware.myNodeNum.toString(),
icon: (
<Avatar
text={device.nodes.get(device.hardware.myNodeNum)?.user
?.shortName ?? device.hardware.myNodeNum.toString()}
/>
),
action() {
setSelectedDevice(device.id);
},
};
}),
subItems: getDevices().map((device) => ({
label: getNode(device.hardware.myNodeNum)?.user?.longName ??
device.hardware.myNodeNum.toString(),
icon: (
<Avatar
text={getNode(device.hardware.myNodeNum)?.user?.shortName ??
device.hardware.myNodeNum.toString()}
/>
),
action() {
setSelectedDevice(device.id);
},
})),
},
{
label: "Connect New Node",
icon: PlusIcon,
action() {
setSelectedDevice(0);
setConnectDialogOpen(true);
},
},
],
@@ -163,15 +163,6 @@ export const CommandPalette = () => {
},
],
},
{
label: "Disconnect",
icon: XCircleIcon,
action() {
void connection?.disconnect();
setSelectedDevice(0);
removeDevice(selectedDevice ?? 0);
},
},
{
label: "Schedule Shutdown",
icon: PowerIcon,
@@ -186,6 +177,13 @@ export const CommandPalette = () => {
setDialogOpen("reboot", true);
},
},
{
label: "Reboot To OTA Mode",
icon: RefreshCwIcon,
action() {
setDialogOpen("rebootOTA", true);
},
},
{
label: "Reset Nodes",
icon: TrashIcon,
@@ -221,16 +219,22 @@ export const CommandPalette = () => {
},
},
{
label: "[WIP] Clear Messages",
label: "Clear All Stored Message",
icon: EraserIcon,
action() {
alert("This feature is not implemented");
setDialogOpen("deleteMessages", true);
},
},
],
},
];
const sortedGroups = [...groups].sort((a, b) => {
const aPinned = pinnedItems.includes(a.label) ? 1 : 0;
const bPinned = pinnedItems.includes(b.label) ? 1 : 0;
return bPinned - aPinned;
});
useEffect(() => {
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
@@ -251,8 +255,39 @@ export const CommandPalette = () => {
<CommandInput placeholder="Type a command or search..." />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
{groups.map((group) => (
<CommandGroup key={group.label} heading={group.label}>
{sortedGroups.map((group) => (
<CommandGroup
key={group.label}
heading={
<div className="flex items-center justify-between">
<span>{group.label}</span>
<button
type="button"
onClick={() => togglePinnedItem(group.label)}
className={cn(
"transition-all duration-300 scale-100 cursor-pointer p-2 focus:*:data-label:opacity-100",
)}
aria-description={pinnedItems.includes(group.label)
? "Unpin command group"
: "Pin command group"}
>
<span
data-label
className="transition-all block absolute w-full mb-auto mt-auto ml-0 mr-0 text-xs left-0 -top-5 opacity-0 rounded-lg"
/>
<Pin
size={16}
className={cn(
"transition-opacity",
pinnedItems.includes(group.label)
? "opacity-100 text-red-500"
: "opacity-40 hover:opacity-70",
)}
/>
</button>
</div>
}
>
{group.commands.map((command) => (
<div key={command.label}>
<CommandItem

View File

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

@@ -3,16 +3,18 @@ import { create } from "@bufbuild/protobuf";
import { Button } from "@components/UI/Button.tsx";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
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;
@@ -23,56 +25,106 @@ 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>
<DialogClose />
<DialogHeader>
<DialogTitle>Change Device Name</DialogTitle>
<DialogDescription>
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

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

View File

@@ -1,8 +1,9 @@
import { create, fromBinary } from "@bufbuild/protobuf";
import { Button } from "@components/UI/Button.tsx";
import { Checkbox } from "@components/UI/Checkbox.tsx";
import { Checkbox } from "../UI/Checkbox/index.tsx";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
@@ -96,6 +97,7 @@ export const ImportDialog = ({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>Import Channel Set</DialogTitle>
<DialogDescription>
@@ -107,7 +109,6 @@ export const ImportDialog = ({
<Input
value={importDialogInput}
suffix={validUrl ? "✅" : "❌"}
className="dark:text-slate-900"
onChange={(e) => {
setImportDialogInput(e.target.value);
}}

View File

@@ -1,6 +1,7 @@
import { useDevice } from "../../core/stores/deviceStore.ts";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
@@ -20,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 ??
@@ -31,6 +32,7 @@ export const LocationResponseDialog = ({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>{`Location: ${longName} (${shortName})`}</DialogTitle>
</DialogHeader>

View File

@@ -7,6 +7,7 @@ import { HTTP } from "@components/PageComponents/Connect/HTTP.tsx";
import { Serial } from "@components/PageComponents/Connect/Serial.tsx";
import {
Dialog,
DialogClose,
DialogContent,
DialogHeader,
DialogTitle,
@@ -52,7 +53,7 @@ const links: { [key: string]: string } = {
const listFormatter = new Intl.ListFormat("en", {
style: "long",
type: "conjunction",
type: "disjunction",
});
const ErrorMessage = ({ missingFeatures }: FeatureErrorProps) => {
@@ -78,16 +79,16 @@ const ErrorMessage = ({ missingFeatures }: FeatureErrorProps) => {
};
return (
<Subtle className="flex flex-col items-start gap-2 text-slate-900 bg-red-200/80 p-4 rounded-md">
<Subtle className="flex flex-col items-start gap-2 bg-red-500 p-4 rounded-md">
<div className="flex items-center gap-2 w-full">
<AlertCircle size={40} className="mr-2 shrink-0" />
<AlertCircle size={40} className="mr-2 shrink-0 text-white" />
<div className="flex flex-col gap-3">
<p className="text-sm">
<p className="text-sm text-white">
{browserFeatures.length > 0 && (
<>
This application requires{" "}
{formatFeatureList(browserFeatures)}. Please use a
Chromium-based browser like Chrome or Edge.
This connection type requires{" "}
{formatFeatureList(browserFeatures)}. Please use a supported
browser, like Chrome or Edge.
</>
)}
{needsSecureContext && (
@@ -134,7 +135,8 @@ export const NewDeviceDialog = ({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogContent aria-describedby={undefined}>
<DialogClose />
<DialogHeader>
<DialogTitle>Connect New Device</DialogTitle>
</DialogHeader>
@@ -149,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,190 +0,0 @@
import { useAppStore } from "../../core/stores/appStore.ts";
import { useDevice } from "../../core/stores/deviceStore.ts";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "../UI/Accordion.tsx";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../UI/Dialog.tsx";
import { Protobuf } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { DeviceImage } from "../generic/DeviceImage.tsx";
import { TimeAgo } from "../generic/TimeAgo.tsx";
import { Uptime } from "../generic/Uptime.tsx";
export interface NodeDetailsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const NodeDetailsDialog = ({
open,
onOpenChange,
}: NodeDetailsDialogProps) => {
const { nodes } = useDevice();
const { nodeNumDetails } = useAppStore();
const device: Protobuf.Mesh.NodeInfo = nodes.get(nodeNumDetails);
return device
? (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
Node Details for {device.user?.longName ?? "UNKNOWN"} (
{device.user?.shortName ?? "UNK"})
</DialogTitle>
</DialogHeader>
<DialogFooter>
<div className="w-full">
<DeviceImage
className="w-32 h-32 mx-auto rounded-lg border-4 border-slate-200 dark:border-slate-800"
deviceType={Protobuf.Mesh
.HardwareModel[device.user?.hwModel ?? 0]}
/>
<div className="bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
<p className="text-lg font-semibold text-slate-900 dark:text-slate-50">
Details:
</p>
<p>
Hardware:{" "}
{Protobuf.Mesh.HardwareModel[device.user?.hwModel ?? 0]}
</p>
<p>Node Number: {device.num}</p>
<p>Node HEX: !{numberToHexUnpadded(device.num)}</p>
<p>
Role: {Protobuf.Config.Config_DeviceConfig_Role[
device.user?.role ?? 0
]}
</p>
<p>
Last Heard: {device.lastHeard === 0
? (
"Never"
)
: <TimeAgo timestamp={device.lastHeard * 1000} />}
</p>
</div>
{device.position
? (
<div className="mt-5 bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
<p className="text-lg font-semibold text-slate-900 dark:text-slate-50">
Position:
</p>
{device.position.latitudeI && device.position.longitudeI
? (
<p>
Coordinates:{" "}
<a
className="text-blue-500 dark:text-blue-400"
href={`https://www.openstreetmap.org/?mlat=${
device.position.latitudeI / 1e7
}&mlon=${
device.position.longitudeI / 1e7
}&layers=N`}
target="_blank"
rel="noreferrer"
>
{device.position.latitudeI / 1e7},{" "}
{device.position.longitudeI / 1e7}
</a>
</p>
)
: null}
{device.position.altitude
? <p>Altitude: {device.position.altitude}m</p>
: null}
</div>
)
: null}
{device.deviceMetrics
? (
<div className="mt-5 bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
<p className="text-lg font-semibold text-slate-900 dark:text-slate-50">
Device Metrics:
</p>
{device.deviceMetrics.airUtilTx
? (
<p>
Air TX utilization:{" "}
{device.deviceMetrics.airUtilTx.toFixed(2)}%
</p>
)
: null}
{device.deviceMetrics.channelUtilization
? (
<p>
Channel utilization:{" "}
{device.deviceMetrics.channelUtilization.toFixed(2)}%
</p>
)
: null}
{device.deviceMetrics.batteryLevel
? (
<p>
Battery level:{" "}
{device.deviceMetrics.batteryLevel.toFixed(2)}%
</p>
)
: null}
{device.deviceMetrics.voltage
? (
<p>
Voltage: {device.deviceMetrics.voltage.toFixed(2)}V
</p>
)
: null}
{device.deviceMetrics.uptimeSeconds
? (
<p>
Uptime:{" "}
<Uptime
seconds={device.deviceMetrics.uptimeSeconds}
/>
</p>
)
: null}
</div>
)
: null}
{device
? (
<div className="mt-5 w-full max-w-[464px] bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
<Accordion
className="AccordionRoot"
type="single"
collapsible
>
<AccordionItem className="AccordionItem" value="item-1">
<AccordionTrigger>
<p className="text-lg font-semibold text-slate-900 dark:text-slate-50">
All Raw Metrics:
</p>
</AccordionTrigger>
<AccordionContent className="overflow-x-scroll">
<pre className="text-xs w-full">
{JSON.stringify(device, null, 2)}
</pre>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
)
: null}
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
: null;
};

View File

@@ -0,0 +1,132 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx";
import { useAppStore } from "@core/stores/appStore.ts";
import { Protobuf } from "@meshtastic/core";
vi.mock("@core/stores/deviceStore", () => {
return {
useDevice: () => ({
setDialogOpen: vi.fn(),
}),
};
});
vi.mock("@core/stores/appStore");
const mockUseAppStore = vi.mocked(useAppStore);
describe("NodeDetailsDialog", () => {
const mockNode = {
num: 1234,
user: {
longName: "Test Node",
shortName: "TN",
hwModel: 1,
role: 1,
},
lastHeard: 1697500000,
position: {
latitudeI: 450000000,
longitudeI: -750000000,
altitude: 200,
},
deviceMetrics: {
airUtilTx: 50.123,
channelUtilization: 75.456,
batteryLevel: 88.789,
voltage: 4.2,
uptimeSeconds: 3600,
},
} as unknown as Protobuf.Mesh.NodeInfo;
beforeEach(() => {
vi.resetAllMocks();
mockUseAppStore.mockReturnValue({
nodeNumDetails: 1234,
});
});
it("renders node details correctly", () => {
render(<NodeDetailsDialog open node={mockNode} onOpenChange={() => {}} />);
expect(screen.getByText(/Node Details for Test Node \(TN\)/i))
.toBeInTheDocument();
expect(screen.getByText("Node Number: 1234")).toBeInTheDocument();
expect(screen.getByText(/Node Hex: !/i)).toBeInTheDocument();
expect(screen.getByText(/Last Heard:/i)).toBeInTheDocument();
expect(screen.getByText(/Coordinates:/i)).toBeInTheDocument();
const link = screen.getByRole("link", { name: /^45, -75$/ });
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute(
"href",
expect.stringContaining("openstreetmap.org"),
);
expect(screen.getByText(/Altitude: 200m/i)).toBeInTheDocument();
expect(screen.getByText(/Air TX utilization: 50.12%/i)).toBeInTheDocument();
expect(screen.getByText(/Channel utilization: 75.46%/i))
.toBeInTheDocument();
expect(screen.getByText(/Battery level: 88.79%/i)).toBeInTheDocument();
expect(screen.getByText(/Voltage: 4.20V/i)).toBeInTheDocument();
expect(screen.getByText(/Uptime:/i)).toBeInTheDocument();
expect(screen.getByText(/All Raw Metrics:/i)).toBeInTheDocument();
});
it("renders null if node is undefined", () => {
const mockNode = undefined;
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

@@ -0,0 +1,350 @@
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,
AccordionItem,
AccordionTrigger,
} from "@components/UI/Accordion.tsx";
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.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 { setDialogOpen, connection, setActivePage } = useDevice();
const { setNodeNumToBeRemoved } = useAppStore();
const { setChatType, setActiveChat } = useMessageStore();
const { updateFavorite } = useFavoriteNode();
const [isFavoriteState, setIsFavoriteState] = useState<boolean>(false);
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: node.deviceMetrics?.airUtilTx,
format: (val: number) => `${val.toFixed(2)}%`,
},
{
key: "channelUtilization",
label: "Channel utilization",
value: node.deviceMetrics?.channelUtilization,
format: (val: number) => `${val.toFixed(2)}%`,
},
{
key: "batteryLevel",
label: "Battery level",
value: node.deviceMetrics?.batteryLevel,
format: (val: number) => `${val.toFixed(2)}%`,
},
{
key: "voltage",
label: "Voltage",
value: node.deviceMetrics?.voltage,
format: (val: number) => `${val.toFixed(2)}V`,
},
];
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent aria-describedby={undefined}>
<DialogClose />
<DialogHeader>
<DialogTitle>
Node Details for {node.user?.longName ?? "UNKNOWN"} (
{node.user?.shortName ?? "UNK"})
</DialogTitle>
</DialogHeader>
<DialogFooter>
<div className="w-full">
<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>
{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 text-slate-900 dark:text-slate-100">
Device Metrics:
</p>
{deviceMetricsMap.map(
(metric) =>
metric.value !== undefined && (
<p key={metric.key}>
{metric.label}: {metric.format(metric.value)}
</p>
),
)}
{node.deviceMetrics.uptimeSeconds && (
<p>
Uptime:{" "}
<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">
<Accordion className="AccordionRoot" type="single" collapsible>
<AccordionItem className="AccordionItem" value="item-1">
<AccordionTrigger>
<p className="text-lg font-semibold text-slate-900 dark:text-slate-50">
All Raw Metrics:
</p>
</AccordionTrigger>
<AccordionContent className="overflow-x-scroll">
<pre className="text-xs w-full">
{JSON.stringify(node, null, 2)}
</pre>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

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

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

View File

@@ -1,6 +1,7 @@
import { Button } from "@components/UI/Button.tsx";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
@@ -9,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,
@@ -22,15 +33,16 @@ export const PkiRegenerateDialog = ({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<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

@@ -1,7 +1,8 @@
import { create, toBinary } from "@bufbuild/protobuf";
import { Checkbox } from "@components/UI/Checkbox.tsx";
import { Checkbox } from "../UI/Checkbox/index.tsx";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
@@ -62,6 +63,7 @@ export const QRDialog = ({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>Generate QR Code</DialogTitle>
<DialogDescription>
@@ -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,7 @@
import { Button } from "@components/UI/Button.tsx";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
@@ -27,6 +28,7 @@ export const RebootDialog = ({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>Schedule Reboot</DialogTitle>
<DialogDescription>

View File

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

View File

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

View File

@@ -0,0 +1,49 @@
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("@core/stores/messageStore");
vi.mock("./useRefreshKeysDialog");
const mockUseMessageStore = vi.mocked(useMessageStore);
const mockUseRefreshKeysDialog = vi.mocked(useRefreshKeysDialog);
const getInitialState = () =>
useDeviceStore.getInitialState?.() ??
{ devices: new Map(), remoteDevices: new Map() };
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

@@ -0,0 +1,86 @@
import {
Dialog,
DialogClose,
DialogContent,
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { Button } from "@components/UI/Button.tsx";
import { LockKeyholeOpenIcon } from "lucide-react";
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
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,
) => {
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"
aria-describedby={undefined}
>
<DialogClose onClick={handleCloseDialog} />
<DialogHeader>
<DialogTitle>{text.title}</DialogTitle>
</DialogHeader>
{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"
/>
</div>
<div className="flex flex-col gap-2">
<div>
<p className="font-bold mb-0.5">Accept New Keys</p>
<p>
This will remove the node from device and request new keys.
</p>
</div>
<Button
variant="default"
onClick={handleNodeRemove}
>
Request New Keys
</Button>
<Button
variant="outline"
onClick={handleCloseDialog}
>
Dismiss
</Button>
</div>
</li>
</ul>
</DialogContent>
</Dialog>
);
};

View File

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

View File

@@ -0,0 +1,28 @@
import { useCallback } from "react";
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 } = useMessageStore();
const handleCloseDialog = useCallback(() => {
setDialogOpen("refreshKeys", false);
}, [setDialogOpen]);
const handleNodeRemove = useCallback(() => {
const nodeWithError = getNodeError(activeChat);
if (!nodeWithError) {
return;
}
clearNodeError(activeChat);
handleCloseDialog();
return removeNode(nodeWithError?.node);
}, [activeChat, clearNodeError, getNodeError, removeNode, handleCloseDialog]);
return {
handleCloseDialog,
handleNodeRemove,
};
}

View File

@@ -3,6 +3,7 @@ import { useDevice } from "@core/stores/deviceStore.ts";
import { Button } from "@components/UI/Button.tsx";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
@@ -20,7 +21,7 @@ export const RemoveNodeDialog = ({
open,
onOpenChange,
}: RemoveNodeDialogProps) => {
const { connection, nodes, removeNode } = useDevice();
const { connection, getNode, removeNode } = useDevice();
const { nodeNumToBeRemoved } = useAppStore();
const onSubmit = () => {
@@ -32,6 +33,7 @@ export const RemoveNodeDialog = ({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>Remove Node?</DialogTitle>
<DialogDescription>
@@ -40,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

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

@@ -1,6 +1,7 @@
import { useDevice } from "../../core/stores/deviceStore.ts";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
@@ -22,20 +23,21 @@ 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>
<DialogClose />
<DialogHeader>
<DialogTitle>{`Traceroute: ${longName} (${shortName})`}</DialogTitle>
</DialogHeader>

View File

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

View File

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

View File

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

View File

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

View File

@@ -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">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 { Controller, 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,10 +15,15 @@ export interface InputFieldProps<T> extends BaseFormBuilderProps<T> {
prefix?: string;
suffix?: string;
step?: number;
action?: {
icon: LucideIcon;
onClick: () => void;
className?: string;
fieldLength?: {
min?: number;
max?: number;
currentValueLength?: number;
showCharacterCount?: boolean;
};
showPasswordToggle?: boolean;
showCopyButton?: boolean;
};
}
@@ -29,42 +32,59 @@ export function GenericInput<T extends FieldValues>({
disabled,
field,
}: GenericFormElementProps<T, InputFieldProps<T>>) {
const [passwordShown, setPasswordShown] = useState(false);
const togglePasswordVisiblity = () => {
setPasswordShown(!passwordShown);
const { fieldLength, ...restProperties } = field.properties || {};
const [currentLength, setCurrentLength] = useState<number>(
fieldLength?.currentValueLength || 0,
);
const { field: controllerField } = useController({
name: field.name,
control,
});
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
if (
field.properties?.fieldLength?.max &&
newValue.length > field.properties?.fieldLength?.max
) {
return;
}
setCurrentLength(newValue.length);
if (field.inputChange) field.inputChange(e);
controllerField.onChange(
field.type === "number"
? Number.parseFloat(newValue).toString()
: newValue,
);
};
return (
<Controller
name={field.name}
control={control}
render={({ field: { value, onChange, ...rest } }) => (
<Input
type={field.type === "password" && passwordShown
? "text"
: field.type}
action={field.type === "password"
? {
icon: passwordShown ? EyeOff : Eye,
onClick: togglePasswordVisiblity,
}
: undefined}
step={field.properties?.step}
value={field.type === "number" ? Number.parseFloat(value) : value}
id={field.name}
onChange={(e) => {
if (field.inputChange) field.inputChange(e);
onChange(
field.type === "number"
? Number.parseFloat(e.target.value)
: e.target.value,
);
}}
{...field.properties}
{...rest}
disabled={disabled}
/>
<div className="relative w-full">
<Input
type={field.type}
step={field.properties?.step}
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-900 dark:text-slate-200">
{currentLength ?? fieldLength?.currentValueLength}/{fieldLength?.max}
</div>
)}
/>
</div>
);
}

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