168 Commits

Author SHA1 Message Date
Dan Ditomaso
e2f03aaf81 feat: add dependency injection using tanstack context (#680)
* feat: add dep injection using tanstack context

* fixed small typo
2025-06-24 18:19:26 -04:00
Dan Ditomaso
6c676fa8da add git sub module (#678) 2025-06-23 22:26:01 -04:00
github-actions[bot]
183b3ae8cc chore(i18n): New Crowdin Translations by GitHub Action (#677)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2025-06-22 13:57:29 -04:00
Dan Ditomaso
43143bfdf6 Device Name dialog validation (#676)
* fix: style on config page

* feat: added device name validation

* Update src/i18n/locales/en/dialog.json

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/components/Dialog/DeviceNameDialog.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/components/Dialog/DeviceNameDialog.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fixed typo

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-06-21 23:08:25 -04:00
Dan Ditomaso
c6a564f7e4 fix: style on config page (#674) 2025-06-20 12:39:11 -04:00
Dan Ditomaso
762aed50b7 Updated button styling on config header (#673)
* fix: updated button in config

* Update src/pages/Config/index.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-06-18 21:08:56 -04:00
Jeremy Gallant
bb91350ef5 fix: Crash when navigator.serial is undefined (#670)
* Fix crash when navigator.serial is undefined

* Change value

---------

Co-authored-by: philon- <philon-@users.noreply.github.com>
2025-06-18 16:39:14 -04:00
Dan Ditomaso
ec9b299b37 fix: add missing i18n strings (#671)
* fix: added required i18n labels to UI

* added node i18n

* updated tests

* updated path to match github action
2025-06-18 16:25:14 -04:00
Jeremy Gallant
0e6a4818ea fix: Crowdin upload sources action (#672)
* Fix Crowdin upload sources action

* Replace biome with deno in vscode extension recommendations

---------

Co-authored-by: philon- <philon-@users.noreply.github.com>
2025-06-18 15:48:41 -04:00
Jeremy Gallant
181c984b27 Config form improvements (#652)
* Config reset work WIP

* Config reset WIP

* Fix tests, tsc, linting

* Form reset adjustments

* Add ManagedModeDialog

* Remove debug logging

* Add Suspense

* Review fixes

---------

Co-authored-by: philon- <philon-@users.noreply.github.com>
2025-06-18 10:04:17 -04:00
Jeremy Gallant
7adbe01723 Add advanced filters (#655)
* Add advanced filters

* Review edits

---------

Co-authored-by: philon- <philon-@users.noreply.github.com>
2025-06-18 10:02:42 -04:00
Dan Ditomaso
a5339af0dd Fix language default in picker. Misc i18n fixes (#664)
* fix: fix language default in picker. Misc i18n fixes

* Update src/i18n/config.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* PR fixes

* duplicate key

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-06-17 16:23:57 -04:00
Dan Ditomaso
c91e5e6b7b fix: add rewrites to vercel.json (#662) 2025-06-17 11:09:43 -04:00
Jeremy Gallant
ccc4202aa4 Minor i18n fixes (#663)
* i18n fixes

Add PKI Backup Reminder dialog + ensure en-US is UI default

* Revert edits to i18n components
2025-06-17 10:24:58 -04:00
Dan Ditomaso
118f848308 feat: add support for 3 languages (#661) 2025-06-16 16:22:45 -04:00
github-actions[bot]
c36ff60778 chore(i18n): New Crowdin Translations by GitHub Action (#660)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2025-06-16 15:59:08 -04:00
Dan Ditomaso
474e610c3d fix: revert crowdin config (#659) 2025-06-16 15:55:29 -04:00
Dan Ditomaso
0b6ae0ce32 fix: update crowdin config (#658) 2025-06-16 15:14:59 -04:00
github-actions[bot]
fad1b984bf chore(i18n): New Crowdin Translations by GitHub Action (#653)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2025-06-16 09:41:50 -04:00
Dan Ditomaso
6cc6986904 fix: remove service worker (#654) 2025-06-16 09:39:13 -04:00
Dan Ditomaso
26d5c0a08a feat: added i18n translator & developer guides (#646) 2025-06-12 20:15:43 -04:00
Dan Ditomaso
78e1d1f81a fix: updated mqtt description (#647) 2025-06-12 20:14:59 -04:00
dependabot[bot]
48862141dc chore(deps): bump tj-actions/changed-files in /.github/workflows (#650)
Bumps [tj-actions/changed-files](https://github.com/tj-actions/changed-files) from 44 to 46.
- [Release notes](https://github.com/tj-actions/changed-files/releases)
- [Changelog](https://github.com/tj-actions/changed-files/blob/main/HISTORY.md)
- [Commits](https://github.com/tj-actions/changed-files/compare/v44...v46)

---
updated-dependencies:
- dependency-name: tj-actions/changed-files
  dependency-version: '46'
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-12 20:10:08 -04:00
Dan Ditomaso
47f8264c31 Fix tsc errors (#649)
* fixed tsc errors

* fixed tsc errors

* fixed tsc errors

* fixing tsc errors

* fixing more tsc errors

* fixing more tsc errors

* fixed tsc errors

* fixing tsc errors

* fixing PR issues

* commented out tsc check

* completing tsc fixes

* updating lockfile

* removed react-hooks
2025-06-12 19:00:30 -04:00
Dan Ditomaso
851da0707c Fix broken tests (#648) 2025-06-12 16:08:08 -04:00
Jeremy Gallant
4275bdd0c0 Remove deprecated meshtastic/js dependency (#638)
* Remove deprecated meshtastic/js dependency

* Bump dependency version

* Fix linting

---------

Co-authored-by: philon- <philon-@users.noreply.github.com>
2025-06-11 10:09:29 -04:00
Dan Ditomaso
828e5d0903 Replace zustand state management with URL-based routing (#640)
* feat: added router

* feat: added params to messages page

* fixing tests, added translation labels

* Update src/i18n/locales/en/ui.json

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/components/PageComponents/Map/NodeDetail.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/pages/Messages.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* updated dev tools

* fixing tests

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-06-06 18:23:49 -04:00
Jeremy Gallant
1cbd98ec53 Zod config validation (#635)
* Zod WIP

* Zod form validation

* DynamicForm testing

* Fix linting

* Delete rasterSource.ts

---------

Co-authored-by: philon- <philon-@users.noreply.github.com>
2025-06-04 08:10:59 -04:00
Dan Ditomaso
df036d3904 Add commit sha to footer. (#636)
* feat: added commit sha to footer.

* added translation

* added padding

* updated styling
2025-06-01 20:41:25 -04:00
Kimberly Graham
08dbe94679 fix i18n files missing and Toast messages (#634)
* fix Toast messages

* Copy i18n files to dist using
vite-plugin-static-copy
2025-05-28 13:55:45 -04:00
Dan Ditomaso
24e9764fcb fix: updated namespace module (#632)
N33d this merged.
2025-05-27 11:13:51 -04:00
Dan Ditomaso
eb2a2717b1 Internationalization (i18n) (#627)
* `feat: added internationalization lib, added english labels`
* `fixes from code review`
* `feat: add crowdin github actions`
* `fix: added support for i18n in testing. Fixed broken tests`
* `fix: missing translations`
* `removed unneded import`
* `more components updated with missing translations`
* `fixed lint issue`
* `Refactor: updated how translations are scoped, updated all references`
* `fixing broken tests`
* `feat: added language switcher, updated some translations`
* `fixed linting issues`
* `reverting vite config`
* `add english group id's to command palette`
* `updated PR template for i18n`
2025-05-27 08:33:43 -04:00
jamon
9d74fe2d6e Remove autocomplete from message box (#630)
* Remove autocomplete from message box

The message box has autocomplete turned on, which causes some browsers to suggest previously written messages in a really annoying way.   This fixes that issue.

* Update src/components/PageComponents/Messages/MessageInput.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-05-27 07:36:16 -04:00
philon-
8413f6345c Fix #624 (#631) 2025-05-27 07:35:47 -04:00
philon-
37a53b747c Fix NodeInfoDialog initiation (#626) 2025-05-27 07:14:57 -04:00
philon-
a642080b90 Filter rework (#623)
* Rework filtering

Created common FilterComponents
Created common FilterControl
Abstracted common map logic into Map component
Reworked Node filtering for map page
Added Node filtering for nodes list
Added test for node filtering
Added toggle group UI package

* Debounce filterState change

* UI adjustments

---------

Co-authored-by: philon- <philon-@users.noreply.github.com>
2025-05-22 15:11:32 -04:00
Dan Ditomaso
9cb5cffdb1 Update README.md
Remove references to `master` replace with `main`
2025-05-21 11:58:03 -04:00
philon-
57b9942cce Add RSSI fields for Paxcounter module (#620)
Co-authored-by: philon- <philon-@users.noreply.github.com>
2025-05-19 12:36:39 -04:00
philon-
1274584497 Feature: Admin PKI fields (#619)
* Add admin PKI fields

* Renaming state ref

* WIP: Refactoring

* Refactoring Security Config

* Ensure admin keys have been set before allowing managed mode
2025-05-16 10:24:41 -04:00
philon-
513a285fee Feature: Node favourites (and ignores) (#618)
* Add favourite icon to node Avatar

* Favourites WIP

* Save isFavorite and isIgnored to device

* Fix spelling

* Clean up

* Always sort favorites first

* Add unread count to "Messages" top level menu

* Renaming, UI tweaks

* Add hook tests

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

Better UI for already "fixed" #351

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

* Update tests

* Remove unused import

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

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

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

* Fix config sidebar button state (#602)

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

* fix: improve how table addresses even/odd rows

---------

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

* fixed several styling issues

* fix: message specific styling/overflow

* added footer, fixed tests. styling

* fix: added theme support back to app component

* fix: hide emojis/reactions

* fix: added more padding to content element

* fix: fixed padding in content element

* updated color scheme

* fix: more dark mode styling improvements

* fix: padding and alignment fixes

* fix: prevent left sidebar collapse, added battery component

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

* message store fixes, ui fixes

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

12
.githooks/_/pre-commit Executable file
View File

@@ -0,0 +1,12 @@
#!/bin/sh
if [ "$SKIP_SIMPLE_GIT_HOOKS" = "1" ]; then
echo "[INFO] SKIP_SIMPLE_GIT_HOOKS is set to 1, skipping hook."
exit 0
fi
if [ -f "$SIMPLE_GIT_HOOKS_RC" ]; then
. "$SIMPLE_GIT_HOOKS_RC"
fi
deno task lint:fix && deno task format

View File

@@ -1,48 +1,50 @@
<!--
Thank you for your contribution to our project! Please fill out the following template to help reviewers understand your changes.
Thank you for your contribution to our project!
-->
## Description
<!--
Provide a clear and concise description of what this PR does. Explain the problem it solves or the feature it adds.
-->
## Related Issues
<!--
Link any related issues here using the GitHub syntax: "Fixes #123" or "Relates to #456".
If there are no related issues, you can remove this section.
-->
## Changes Made
<!--
List the key changes you've made. Focus on the most important aspects that reviewers should understand.
-->
-
-
-
-
-
-
-
## Testing Done
<!--
Describe how you tested these changes.
Describe how you tested these changes (added new tests, etc).
-->
## Screenshots (if applicable)
<!--
If your changes affect the UI, include screenshots or screencasts showing the before and after.
-->
## Checklist
<!--
Check all that apply. If an item doesn't apply to your PR, you can leave it unchecked or remove it.
-->
- [ ] Code follows project style guidelines
- [ ] Documentation has been updated or added
- [ ] Tests have been added or updated
- [ ] All CI checks pass
- [ ] Dependent changes have been merged
## Additional Notes
<!--
Add any other context about the PR here.
-->
- [ ] All i18n translation labels have been added (read
CONTRIBUTING_I18N_DEVELOPER_GUIDE.md for more details)

View File

@@ -1,9 +1,9 @@
name: CI
name: Push to Main CI
on:
push:
branches:
- master
- main
permissions:
contents: write
@@ -24,6 +24,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

35
.github/workflows/crowdin-download.yml vendored Normal file
View File

@@ -0,0 +1,35 @@
name: Crowdin Download Translations Action
on:
schedule: # Every Sunday at midnight
- cron: '0 0 * * 0'
workflow_dispatch: # Allow manual triggering
jobs:
synchronize-with-crowdin:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download translations with Crowdin
uses: crowdin/github-action@v2
with:
base_url: 'https://meshtastic.crowdin.com/api/v2'
config: 'crowdin.yml'
upload_sources: false
upload_translations: false
download_translations: true
localization_branch_name: i18n_crowdin_translations
commit_message: 'chore(i18n): New Crowdin Translations by GitHub Action'
create_pull_request: true
pull_request_title: 'chore(i18n): New Crowdin Translations'
pull_request_body: 'New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)'
pull_request_base_branch_name: 'main'
pull_request_labels: 'i18n'
crowdin_branch_name: 'main'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}

View File

@@ -0,0 +1,32 @@
name: Crowdin Upload Sources Action
on:
push:
# Monitor all .json files within the /src/i18n/locales/en/ directory.
# This ensures the workflow triggers if any the English namespace files are modified on the main branch.
paths:
- "/src/i18n/locales/en/*.json"
branches: [main]
workflow_dispatch: # Allow manual triggering
jobs:
synchronize-with-crowdin:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Upload sources with Crowdin
uses: crowdin/github-action@v2
with:
base_url: "https://meshtastic.crowdin.com/api/v2"
config: "crowdin.yml"
upload_sources: true
upload_translations: false
download_translations: false
crowdin_branch_name: "main"
env:
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}

View File

@@ -0,0 +1,25 @@
name: Crowdin Upload Translations Action
on:
workflow_dispatch: # Allow manual triggering
jobs:
synchronize-with-crowdin:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Upload translations with Crowdin
uses: crowdin/github-action@v2
with:
base_url: "https://meshtastic.crowdin.com/api/v2"
config: "crowdin.yml"
upload_sources: false
upload_translations: true
download_translations: false
crowdin_branch_name: "main"
env:
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}

View File

@@ -1,6 +1,7 @@
name: Pull Request
name: Pull Request CI
on: pull_request
on:
pull_request:
jobs:
build:
@@ -14,9 +15,43 @@ 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: Get changed files
id: changed-files
uses: tj-actions/changed-files@v46
with:
files: |
**/*.ts
**/*.tsx
# Uncomment the following lines when you have figured out how to ignore files
# - name: Type check changed files
# if: steps.changed-files.outputs.all_changed_files != ''
# run: deno check ${{ steps.changed-files.outputs.all_changed_files }}
- name: Run linter on changed files
if: steps.changed-files.outputs.all_changed_files != ''
run: deno task lint ${{ steps.changed-files.outputs.all_changed_files }}
- name: Check format on changed files
if: steps.changed-files.outputs.all_changed_files != ''
run: deno task format --check ${{ steps.changed-files.outputs.all_changed_files }}
- name: Run tests
run: deno task test

View File

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

View File

@@ -0,0 +1,50 @@
name: Update Stable Branch from Main on Latest Release
on:
release:
types: [released]
permissions:
contents: write
jobs:
update-stable-branch:
name: Update Stable Branch from Main
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 main and stable branches
run: |
git fetch origin main:main
git fetch origin stable:stable || echo "Stable branch not found remotely, will create."
- name: Get latest main commit SHA
id: get_main_sha
run: echo "MAIN_SHA=$(git rev-parse main)" >> $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 main HEAD."
git checkout -b stable ${{ env.MAIN_SHA }}
fi
- name: Reset stable branch to latest main
run: git reset --hard ${{ env.MAIN_SHA }}
- name: Force push stable branch
run: git push origin stable --force

3
.gitignore vendored
View File

@@ -4,4 +4,5 @@ stats.html
.vercel
.vite
dev-dist
__screenshots__*
__screenshots__*
*.diff

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "src/core/connection"]
path = src/core/connection
url = https://github.com/meshtastic/js.git

View File

@@ -1,3 +1,3 @@
{
"recommendations": ["bradlc.vscode-tailwindcss", "biomejs.biome"]
"recommendations": ["bradlc.vscode-tailwindcss", "denoland.vscode-deno"]
}

View File

@@ -0,0 +1,112 @@
# i18n Developer Guide
When developing new components, all user-facing text must be added as an i18n
key and rendered using our translation functions. This ensures your UI can be
translated into multiple languages.
## Adding New i18n Keys
### Search Before Creating
Before adding a new key, please perform a quick search to see if one that fits
your needs already exists. Many common labels like "Save," "Cancel," "Name,"
"Description," "Loading...," or "Error" are likely already present, especially
in the common.json namespace. Reusing existing keys prevents duplication and
ensures consistency across the application. Using your code editor's search
function across the /src/i18n/locales/en/ directory is an effective way to do
this.
### Key Naming and Structure Rules
To maintain consistency and ease of use, please adhere to the following rules
when creating new keys in the JSON files.
- **Keys are camelCase:** `exampleKey`, `anotherExampleKey`.
- **Avoid Deep Nesting:** One or two levels of nesting are acceptable for
grouping related keys (e.g., all labels for a specific menu). However, nesting
deeper than two levels should be avoided to maintain readability and ease of
use.
- **Good (1 level):**
```json
"buttons": {
"save": "Save",
"cancel": "Cancel"
}
```
- **Acceptable (2 levels):**
```json
"userMenu": {
"items": {
"profile": "Profile",
"settings": "Settings"
}
}
```
- **Avoid (3+ levels):**
```json
"userMenu": {
"items": {
"actions": {
"viewProfile": "View Profile"
}
}
}
```
- **Organize for Retrieval, Not UI Layout:** Keys should be named logically for
easy retrieval, not to mirror the layout of your component.
### Namespace Rules
We use namespaces to organize keys. All source keys are added to the English
(`en`) files located at `/src/i18n/locales/en/`. Place your new keys in the
appropriate file based on these rules:
- `common.json`:
- All button labels (`save`, `cancel`, `submit`, etc.).
- Any text that is repeated and used throughout the application (e.g.,
"Loading...", "Error").
- `ui.json`:
- Labels and text specific to a distinct UI element or view that isn't a
dialog or a config page.
- `dialog.json`:
- All text specific to modal dialogs (titles, body text, prompts).
- `messages.json`:
- Text specifically related to the messaging interface.
- `deviceConfig.json` & `moduleConfig.json`:
- Labels and descriptions for the settings on the Device and Module
configuration pages.
## Using i18n Keys in Components
We use the `useTranslation` hook from `react-i18next` to access the translation
function, `t`.
### Default Namespaces
Our i18next configuration has fallback namespaces configured which includes
`common`, `ui`, and `dialog`. This means you **do not** need to explicitly
specify these namespaces when calling the hook. The system will automatically
check these files for your key.
For any keys in `common.json`, `ui.json`, or `dialog.json`, you can instantiate
the hook simply:
```typescript
import { useTranslation } from "react-i18next";
// In your component
const { t } = useTranslation(["messages"]);
// Usage
return <p>{t("someMessageLabel")}</p>;
```
You can also specify the namespace on a per-call basis using the options object.
This is useful if a component primarily uses a default namespace but needs a
single key from another.
```typescript
const { t } = useTranslation();
return <p>{t("someMessageLabel", { ns: "messages" })}</p>;
```

View File

@@ -0,0 +1,31 @@
# Contributing Translations
Thank you for your interest in making the Meshtastic Web Client accessible to a
global audience! Your translation efforts are greatly appreciated.
## Our Translation Platform: Crowdin
We manage all our translations through a platform called
[Crowdin](https://crowdin.com/). This allows for a collaborative and streamlined
translation process. All translation work should be done on our Crowdin project,
not directly in the code repository via Pull Requests.
### How to Get Started
1. **Create a Crowdin Account:** If you don't already have one, sign up for a
free account on Crowdin.
2. **Join Our Project:** Please ask for a link to our specific Crowdin project
on the Meshtastic Discord.
3. **Request Translator Role:** Once you have an account, join the Meshtastic
Discord and notify an admin in the `#web` channel. They will grant you the
necessary permissions to start translating.
4. **Start Translating:** Once you have your role, you can begin translating the
source labels into your native language directly on the Crowdin platform.
### Language Activation
A new language will only be added to the web client and appear in the language
picker once its translation is 100% complete on Crowdin. The repository
maintainers will handle this process once the milestone is reached.
Thank you for helping us bring Meshtastic to more users around the world!

View File

@@ -2,7 +2,7 @@
<!--Project specific badges here-->
[![CI](https://img.shields.io/github/actions/workflow/status/meshtastic/web/ci.yml?branch=master&label=actions&logo=github&color=yellow)](https://github.com/meshtastic/web/actions/workflows/ci.yml)
[![CI](https://img.shields.io/github/actions/workflow/status/meshtastic/web/ci.yml?branch=main&label=actions&logo=github&color=yellow)](https://github.com/meshtastic/web/actions/workflows/ci.yml)
[![CLA assistant](https://cla-assistant.io/readme/badge/meshtastic/web)](https://cla-assistant.io/meshtastic/web)
[![Fiscal Contributors](https://opencollective.com/meshtastic/tiers/badge.svg?label=Fiscal%20Contributors&color=deeppink)](https://opencollective.com/meshtastic/)
[![Vercel](https://img.shields.io/static/v1?label=Powered%20by&message=Vercel&style=flat&logo=vercel&color=000000)](https://vercel.com?utm_source=meshtastic&utm_campaign=oss)
@@ -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
`main` 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:
@@ -161,6 +170,6 @@ requests:
Meshtastic nodes.
Please review our
[Contribution Guidelines](https://github.com/meshtastic/web/blob/master/CONTRIBUTING.md)
[Contribution Guidelines](https://github.com/meshtastic/web/blob/main/CONTRIBUTING.md)
before submitting a pull request. We appreciate your help in making the project
better!

10
crowdin.yml Normal file
View File

@@ -0,0 +1,10 @@
project_id_env: CROWDIN_PROJECT_ID
api_token_env: CROWDIN_PERSONAL_TOKEN
base_path: "."
base_url: "https://meshtastic.crowdin.com/api/v2"
preserve_hierarchy: true
files:
- source: "/src/i18n/locales/en/*.json"
translation: "/src/i18n/locales/%locale%/%original_file_name%"

View File

@@ -4,8 +4,10 @@
"@pages/": "./src/pages/",
"@components/": "./src/components/",
"@core/": "./src/core/",
"@layouts/": "./src/layouts/"
"@layouts/": "./src/layouts/",
"@std/path": "jsr:@std/path@^1.1.0"
},
"include": ["src", "./vite-env.d.ts"],
"compilerOptions": {
"lib": [
"DOM",
@@ -23,11 +25,35 @@
"types": [
"vite/client",
"node",
"@types/web-bluetooth",
"@types/w3c-web-serial"
"npm:@types/w3c-web-serial",
"npm:@types/web-bluetooth"
],
"strictPropertyInitialization": false
},
"fmt": {
"exclude": [
"src/routeTree.gen.ts",
"*.test.ts",
"*.test.tsx"
]
},
"lint": {
"exclude": [
"src/routeTree.gen.ts",
"*.test.ts",
"*.test.tsx"
],
"report": "pretty"
},
"exclude": [
"routeTree.gen.ts",
"node_modules/",
"dist",
"build",
"coverage",
"out",
".vscode-test"
],
"unstable": [
"sloppy-imports"
]

2973
deno.lock generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -11,8 +11,9 @@
"lint:fix": "deno lint --fix src/",
"format": "deno fmt src/",
"dev": "deno task dev:ui",
"dev:ui": "deno run -A npm:vite dev",
"dev:ui": "VITE_APP_VERSION=development deno run -A npm:vite dev",
"test": "deno run -A npm:vitest",
"check": "deno check",
"preview": "deno run -A npm:vite preview",
"package": "gzipper c -i html,js,css,png,ico,svg,webmanifest,txt dist dist/output && tar -cvf dist/build.tar -C ./dist/output/ ."
},
@@ -34,76 +35,87 @@
},
"homepage": "https://meshtastic.org",
"dependencies": {
"@meshtastic/core": "npm:@jsr/meshtastic__core@2.6.2",
"@bufbuild/protobuf": "^2.2.5",
"@hookform/resolvers": "^5.1.1",
"@meshtastic/core": "npm:@jsr/meshtastic__core@2.6.4",
"@meshtastic/js": "npm:@jsr/meshtastic__js@2.6.0-0",
"@meshtastic/transport-http": "npm:@jsr/meshtastic__transport-http",
"@meshtastic/transport-web-bluetooth": "npm:@jsr/meshtastic__transport-web-bluetooth",
"@meshtastic/transport-web-serial": "npm:@jsr/meshtastic__transport-web-serial",
"@bufbuild/protobuf": "^2.2.5",
"@noble/curves": "^1.8.1",
"@radix-ui/react-accordion": "^1.2.3",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-menubar": "^1.1.6",
"@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-scroll-area": "^1.2.3",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-toast": "^1.2.6",
"@radix-ui/react-tooltip": "^1.1.8",
"@noble/curves": "^1.9.0",
"@radix-ui/react-accordion": "^1.2.8",
"@radix-ui/react-checkbox": "^1.2.3",
"@radix-ui/react-dialog": "^1.1.11",
"@radix-ui/react-dropdown-menu": "^2.1.12",
"@radix-ui/react-label": "^2.1.4",
"@radix-ui/react-menubar": "^1.1.12",
"@radix-ui/react-popover": "^1.1.11",
"@radix-ui/react-scroll-area": "^1.2.6",
"@radix-ui/react-select": "^2.2.2",
"@radix-ui/react-separator": "^1.1.4",
"@radix-ui/react-slider": "^1.3.2",
"@radix-ui/react-switch": "^1.2.2",
"@radix-ui/react-tabs": "^1.1.9",
"@radix-ui/react-toast": "^1.2.11",
"@radix-ui/react-toggle-group": "^1.1.9",
"@radix-ui/react-tooltip": "^1.2.4",
"@tanstack/react-router": "^1.120.15",
"@tanstack/react-router-devtools": "^1.120.16",
"@tanstack/router-devtools": "^1.120.15",
"@turf/turf": "^7.2.0",
"@types/web-bluetooth": "^0.0.21",
"base64-js": "^1.5.1",
"class-validator": "^0.14.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"crypto-random-string": "^5.0.0",
"i18next": "^25.2.0",
"i18next-browser-languagedetector": "^8.1.0",
"i18next-http-backend": "^3.0.2",
"idb-keyval": "^6.2.1",
"immer": "^10.1.1",
"js-cookie": "^3.0.5",
"lucide-react": "^0.486.0",
"maplibre-gl": "5.3.0",
"lucide-react": "^0.507.0",
"maplibre-gl": "5.4.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-error-boundary": "^5.0.0",
"react-hook-form": "^7.55.0",
"react-map-gl": "8.0.2",
"react-error-boundary": "^6.0.0",
"react-hook-form": "^7.56.2",
"react-i18next": "^15.5.1",
"react-map-gl": "8.0.4",
"react-qrcode-logo": "^3.0.0",
"rfc4648": "^1.5.4",
"vite-plugin-node-polyfills": "^0.23.0",
"zod": "^3.24.2",
"zustand": "5.0.3"
"zod": "^3.25.67",
"zustand": "5.0.5"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.0",
"@tailwindcss/postcss": "^4.1.5",
"@tanstack/router-plugin": "^1.120.15",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/chrome": "^0.0.313",
"@types/chrome": "^0.0.318",
"@types/node": "^22.15.3",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.3",
"@types/serviceworker": "^0.0.133",
"@types/js-cookie": "^3.0.6",
"@types/node": "^22.13.17",
"@types/react": "^19.0.12",
"@types/react-dom": "^19.0.4",
"@types/serviceworker": "^0.0.127",
"@types/w3c-web-serial": "^1.0.8",
"@types/web-bluetooth": "^0.0.21",
"@vitejs/plugin-react": "^4.3.4",
"@vitejs/plugin-react": "^4.4.1",
"autoprefixer": "^10.4.21",
"gzipper": "^8.2.1",
"happy-dom": "^17.4.4",
"happy-dom": "^17.4.6",
"postcss": "^8.5.3",
"simple-git-hooks": "^2.12.1",
"tailwind-merge": "^3.1.0",
"tailwindcss": "^4.1.0",
"simple-git-hooks": "^2.13.0",
"tailwind-merge": "^3.2.0",
"tailwindcss": "^4.1.5",
"tailwindcss-animate": "^1.0.7",
"tar": "^7.4.3",
"testing-library": "^0.0.2",
"typescript": "^5.8.2",
"vite": "^6.2.4",
"vitest": "^3.1.1",
"vite-plugin-pwa": "^1.0.0"
"typescript": "^5.8.3",
"vite": "^6.3.4",
"vite-plugin-pwa": "^1.0.0",
"vite-plugin-static-copy": "^3.0.0",
"vitest": "^3.1.2"
}
}

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

@@ -1,11 +1,11 @@
{
"name": "Meshtastic",
"short_name": "Meshtastic",
"short_name": "Web Client",
"start_url": ".",
"description": "Meshtastic web app",
"description": "Meshtastic Web App",
"icons": [
{
"src": "/icon.svg",
"src": "/Logo.svg",
"sizes": "any",
"type": "image/svg+xml"
}

View File

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

View File

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

View File

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

View File

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

View File

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 112 KiB

View File

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

View File

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

View File

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

View File

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

View File

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

View File

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 91 KiB

View File

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 120 KiB

View File

@@ -1,6 +1,4 @@
import { DeviceWrapper } from "@app/DeviceWrapper.tsx";
import { PageRouter } from "@app/PageRouter.tsx";
import { DeviceSelector } from "@components/DeviceSelector.tsx";
import { DialogManager } from "@components/Dialog/DialogManager.tsx";
import { NewDeviceDialog } from "@components/Dialog/NewDeviceDialog.tsx";
import { KeyBackupReminder } from "@components/KeyBackupReminder.tsx";
@@ -9,20 +7,25 @@ import Footer from "@components/UI/Footer.tsx";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDeviceStore } from "@core/stores/deviceStore.ts";
import { Dashboard } from "@pages/Dashboard/index.tsx";
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";
import { Outlet } from "@tanstack/react-router";
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
export const App = (): JSX.Element => {
export function App() {
const { getDevice } = useDeviceStore();
const { selectedDevice, setConnectDialogOpen, connectDialogOpen } =
useAppStore();
const device = getDevice(selectedDevice);
// Sets up light/dark mode based on user preferences or system settings
useTheme();
return (
<ErrorBoundary FallbackComponent={ErrorPage}>
<NewDeviceDialog
@@ -32,30 +35,35 @@ export const App = (): JSX.Element => {
}}
/>
<Toaster />
<TanStackRouterDevtools position="bottom-right" />
<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>
<Outlet />
</MapProvider>
</div>
)
: (
<>
<Dashboard />
<Footer />
</>
)}
</div>
</div>
</SidebarProvider>
</div>
</DeviceWrapper>
</ErrorBoundary>
);
};
}

View File

@@ -1,27 +0,0 @@
import MapPage from "@app/pages/Map/index.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import ChannelsPage from "@pages/Channels.tsx";
import ConfigPage from "@pages/Config/index.tsx";
import MessagesPage from "@pages/Messages.tsx";
import NodesPage from "@pages/Nodes.tsx";
import { ErrorBoundary } from "react-error-boundary";
import { ErrorPage } from "@components/UI/ErrorPage.tsx";
export const ErrorBoundaryWrapper = ({
children,
}: { children: React.ReactNode }) => (
<ErrorBoundary FallbackComponent={ErrorPage}>{children}</ErrorBoundary>
);
export const PageRouter = () => {
const { activePage } = useDevice();
return (
<ErrorBoundary FallbackComponent={ErrorPage}>
{activePage === "messages" && <MessagesPage />}
{activePage === "map" && <MapPage />}
{activePage === "config" && <ConfigPage />}
{activePage === "channels" && <ChannelsPage />}
{activePage === "nodes" && <NodesPage />}
</ErrorBoundary>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,102 @@
import React from "react";
import {
BatteryFullIcon,
BatteryLowIcon,
BatteryMediumIcon,
PlugZapIcon,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { DeviceMetrics } from "./types.ts";
type BatteryStatusKey = keyof typeof BATTERY_STATUS;
interface BatteryStatusProps {
deviceMetrics?: DeviceMetrics | null;
}
interface BatteryStatusProps {
deviceMetrics?: DeviceMetrics | null;
}
interface StatusConfig {
Icon: React.ElementType;
className: string;
text: string;
}
const BATTERY_STATUS = {
PLUGGED_IN: "PLUGGED_IN",
FULL: "FULL",
MEDIUM: "MEDIUM",
LOW: "LOW",
} as const;
export const getBatteryStatus = (level: number): BatteryStatusKey => {
if (level > 100) {
return BATTERY_STATUS.PLUGGED_IN;
}
if (level > 80) {
return BATTERY_STATUS.FULL;
}
if (level > 20) {
return BATTERY_STATUS.MEDIUM;
}
return BATTERY_STATUS.LOW;
};
const BatteryStatus: React.FC<BatteryStatusProps> = ({ deviceMetrics }) => {
const { t } = useTranslation();
if (
deviceMetrics?.batteryLevel === undefined ||
deviceMetrics?.batteryLevel === null
) {
return null;
}
const { batteryLevel } = deviceMetrics;
const statusKey = getBatteryStatus(batteryLevel);
const statusConfigMap: Record<BatteryStatusKey, StatusConfig> = {
[BATTERY_STATUS.PLUGGED_IN]: {
Icon: PlugZapIcon,
className: "text-gray-500",
text: t("batteryStatus.pluggedIn"),
},
[BATTERY_STATUS.FULL]: {
Icon: BatteryFullIcon,
className: "text-green-500",
text: t("batteryStatus.charging", { level: batteryLevel }),
},
[BATTERY_STATUS.MEDIUM]: {
Icon: BatteryMediumIcon,
className: "text-yellow-500",
text: t("batteryStatus.charging", { level: batteryLevel }),
},
[BATTERY_STATUS.LOW]: {
Icon: BatteryLowIcon,
className: "text-red-500",
text: t("batteryStatus.charging", { level: batteryLevel }),
},
};
// 3. Use the key to get the current state configuration
const {
Icon: BatteryIcon,
className: iconClassName,
text: statusText,
} = statusConfigMap[statusKey];
return (
<div
className="flex items-center gap-1 mt-0.5 "
aria-label={t("batteryStatus.title")}
>
<BatteryIcon size={22} className={iconClassName} />
{statusText}
</div>
);
};
export default BatteryStatus;

View File

@@ -17,8 +17,10 @@ import {
FactoryIcon,
LayersIcon,
LinkIcon,
type LucideIcon,
MapIcon,
MessageSquareIcon,
Pin,
PlusIcon,
PowerIcon,
QrCodeIcon,
@@ -27,15 +29,16 @@ import {
SmartphoneIcon,
TrashIcon,
UsersIcon,
Pin,
type LucideIcon,
} from "lucide-react";
import { useEffect } from "react";
import { Avatar } from "@components/UI/Avatar.tsx";
import { cn } from "@core/utils/cn.ts";
import { useTranslation } from "react-i18next";
import { usePinnedItems } from "@core/hooks/usePinnedItems.ts";
import { useNavigate } from "@tanstack/react-router";
export interface Group {
id: string;
label: string;
icon: LucideIcon;
commands: Command[];
@@ -57,72 +60,76 @@ export const CommandPalette = () => {
const {
commandPaletteOpen,
setCommandPaletteOpen,
setConnectDialogOpen,
setSelectedDevice,
} = useAppStore();
const { getDevices } = useDeviceStore();
const { setDialogOpen, setActivePage, connection } = useDevice();
const { pinnedItems, togglePinnedItem } = usePinnedItems({ storageName: 'pinnedCommandMenuGroups' });
const { setDialogOpen, getNode, connection } = useDevice();
const { pinnedItems, togglePinnedItem } = usePinnedItems({
storageName: "pinnedCommandMenuGroups",
});
const { t } = useTranslation("commandPalette");
const navigate = useNavigate({ from: "/" });
const groups: Group[] = [
{
label: "Goto",
id: "gotoGroup",
label: t("goto.label"),
icon: LinkIcon,
commands: [
{
label: "Messages",
label: t("goto.command.messages"),
icon: MessageSquareIcon,
action() {
setActivePage("messages");
navigate({ to: "/messages" });
},
},
{
label: "Map",
label: t("goto.command.map"),
icon: MapIcon,
action() {
setActivePage("map");
navigate({ to: "/map" });
},
},
{
label: "Config",
label: t("goto.command.config"),
icon: SettingsIcon,
action() {
setActivePage("config");
navigate({ to: "/config" });
},
tags: ["settings"],
},
{
label: "Channels",
label: t("goto.command.channels"),
icon: LayersIcon,
action() {
setActivePage("channels");
navigate({ to: "/channels" });
},
},
{
label: "Nodes",
label: t("goto.command.nodes"),
icon: UsersIcon,
action() {
setActivePage("nodes");
navigate({ to: "/nodes" });
},
},
],
},
{
label: "Manage",
id: "manageGroup",
label: t("manage.label"),
icon: SmartphoneIcon,
commands: [
{
label: "Switch Node",
label: t("manage.command.switchNode"),
icon: ArrowLeftRightIcon,
subItems: getDevices().map((device) => ({
label:
device.nodes.get(device.hardware.myNodeNum)?.user?.longName ??
device.hardware.myNodeNum.toString(),
label: getNode(device.hardware.myNodeNum)?.user?.longName ??
t("unknown.shortName"),
icon: (
<Avatar
text={
device.nodes.get(device.hardware.myNodeNum)?.user?.shortName ??
device.hardware.myNodeNum.toString()
}
text={getNode(device.hardware.myNodeNum)?.user?.shortName ??
t("unknown.shortName")}
/>
),
action() {
@@ -131,31 +138,32 @@ export const CommandPalette = () => {
})),
},
{
label: "Connect New Node",
label: t("manage.command.connectNewNode"),
icon: PlusIcon,
action() {
setSelectedDevice(0);
setConnectDialogOpen(true);
},
},
],
},
{
label: "Contextual",
id: "contextualGroup",
label: t("contextual.label"),
icon: BoxSelectIcon,
commands: [
{
label: "QR Code",
label: t("contextual.command.qrCode"),
icon: QrCodeIcon,
subItems: [
{
label: "Generator",
label: t("contextual.command.qrGenerator"),
icon: <QrCodeIcon size={16} />,
action() {
setDialogOpen("QR", true);
},
},
{
label: "Import",
label: t("contextual.command.qrImport"),
icon: <QrCodeIcon size={16} />,
action() {
setDialogOpen("import", true);
@@ -164,42 +172,42 @@ export const CommandPalette = () => {
],
},
{
label: "Schedule Shutdown",
label: t("contextual.command.scheduleShutdown"),
icon: PowerIcon,
action() {
setDialogOpen("shutdown", true);
},
},
{
label: "Schedule Reboot",
label: t("contextual.command.scheduleReboot"),
icon: RefreshCwIcon,
action() {
setDialogOpen("reboot", true);
},
},
{
label: "Reboot To OTA Mode",
label: t("contextual.command.rebootToOtaMode"),
icon: RefreshCwIcon,
action() {
setDialogOpen("rebootOTA", true);
},
},
{
label: "Reset Nodes",
label: t("contextual.command.resetNodeDb"),
icon: TrashIcon,
action() {
connection?.resetNodes();
},
},
{
label: "Factory Reset Device",
label: t("contextual.command.factoryResetDevice"),
icon: FactoryIcon,
action() {
connection?.factoryResetDevice();
},
},
{
label: "Factory Reset Config",
label: t("contextual.command.factoryResetConfig"),
icon: FactoryIcon,
action() {
connection?.factoryResetConfig();
@@ -208,21 +216,22 @@ export const CommandPalette = () => {
],
},
{
label: "Debug",
id: "debugGroup",
label: t("debug.label"),
icon: BugIcon,
commands: [
{
label: "Reconfigure",
label: t("debug.command.reconfigure"),
icon: RefreshCwIcon,
action() {
void connection?.configure();
},
},
{
label: "[WIP] Clear Messages",
label: t("debug.command.clearAllStoredMessages"),
icon: EraserIcon,
action() {
alert("This feature is not implemented");
setDialogOpen("deleteMessages", true);
},
},
],
@@ -230,8 +239,8 @@ export const CommandPalette = () => {
];
const sortedGroups = [...groups].sort((a, b) => {
const aPinned = pinnedItems.includes(a.label) ? 1 : 0;
const bPinned = pinnedItems.includes(b.label) ? 1 : 0;
const aPinned = pinnedItems.includes(a.id) ? 1 : 0;
const bPinned = pinnedItems.includes(b.id) ? 1 : 0;
return bPinned - aPinned;
});
@@ -248,10 +257,13 @@ export const CommandPalette = () => {
}, [setCommandPaletteOpen]);
return (
<CommandDialog open={commandPaletteOpen} onOpenChange={setCommandPaletteOpen}>
<CommandInput placeholder="Type a command or search..." />
<CommandDialog
open={commandPaletteOpen}
onOpenChange={setCommandPaletteOpen}
>
<CommandInput placeholder={t("search.commandPalette")} />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandEmpty>{t("emptyState")}</CommandEmpty>
{sortedGroups.map((group) => (
<CommandGroup
key={group.label}
@@ -260,15 +272,13 @@ export const CommandPalette = () => {
<span>{group.label}</span>
<button
type="button"
onClick={() => togglePinnedItem(group.label)}
onClick={() => togglePinnedItem(group.id)}
className={cn(
"transition-all duration-300 scale-100 cursor-pointer m-0.5 p-2 focus:*:data-label:opacity-100"
"transition-all duration-300 scale-100 cursor-pointer p-2 focus:*:data-label:opacity-100",
)}
aria-description={
pinnedItems.includes(group.label)
? "Unpin command group"
: "Pin command group"
}
aria-description={pinnedItems.includes(group.label)
? t("unpinGroup.label")
: t("pinGroup.label")}
>
<span
data-label
@@ -278,9 +288,9 @@ export const CommandPalette = () => {
size={16}
className={cn(
"transition-opacity",
pinnedItems.includes(group.label)
pinnedItems.includes(group.id)
? "opacity-100 text-red-500"
: "opacity-40 hover:opacity-70"
: "opacity-40 hover:opacity-70",
)}
/>
</button>

View File

@@ -0,0 +1,239 @@
import { cn } from "@core/utils/cn.ts";
import {
CpuIcon,
Languages,
type LucideIcon,
Palette,
PenLine,
Search as SearchIcon,
ZapIcon,
} from "lucide-react";
import BatteryStatus from "./BatteryStatus.tsx";
import { Subtle } from "./UI/Typography/Subtle.tsx";
import { Avatar } from "./UI/Avatar.tsx";
import type { DeviceMetrics } from "./types.ts";
import { Button } from "./UI/Button.tsx";
import React, { Fragment } from "react";
import { useTranslation } from "react-i18next";
import ThemeSwitcher from "./ThemeSwitcher.tsx";
import LanguageSwitcher from "./LanguageSwitcher.tsx";
interface DeviceInfoPanelProps {
isCollapsed: boolean;
deviceMetrics: DeviceMetrics;
firmwareVersion: string;
user: {
shortName: string;
longName: string;
};
setDialogOpen: () => void;
setCommandPaletteOpen: () => void;
disableHover?: boolean;
}
interface InfoDisplayItem {
id: string;
label: string;
icon?: LucideIcon;
customComponent?: React.ReactNode;
value?: string | number | null;
}
interface ActionButtonConfig {
id: string;
label: string;
icon: LucideIcon;
onClick?: () => void;
render?: () => React.ReactNode;
}
export const DeviceInfoPanel = ({
deviceMetrics,
firmwareVersion,
user,
isCollapsed,
setDialogOpen,
setCommandPaletteOpen,
disableHover = false,
}: DeviceInfoPanelProps) => {
const { t } = useTranslation();
const { batteryLevel, voltage } = deviceMetrics;
const deviceInfoItems: InfoDisplayItem[] = [
{
id: "battery",
label: t("batteryStatus.title"),
customComponent: <BatteryStatus deviceMetrics={deviceMetrics} />,
value: batteryLevel !== undefined ? `${batteryLevel}%` : "N/A",
},
{
id: "voltage",
label: t("batteryVoltage.title"),
icon: ZapIcon,
value: voltage !== undefined
? `${voltage?.toPrecision(3)} V`
: t("unknown.notAvailable", "N/A"),
},
{
id: "firmware",
label: t("sidebar.deviceInfo.firmware.title"),
icon: CpuIcon,
value: firmwareVersion ?? t("unknown.notAvailable", "N/A"),
},
];
const actionButtons: ActionButtonConfig[] = [
{
id: "changeName",
label: t("sidebar.deviceInfo.deviceName.changeName"),
icon: PenLine,
onClick: setDialogOpen,
},
{
id: "commandMenu",
label: t("page.title", { ns: "commandPalette" }),
icon: SearchIcon,
onClick: setCommandPaletteOpen,
},
{
id: "theme",
label: t("theme.changeTheme"),
icon: Palette,
render: () => <ThemeSwitcher />,
},
{
id: "language",
label: t("language.changeLanguage"),
icon: Languages,
render: () => <LanguageSwitcher />,
},
];
return (
<>
<div
className={cn(
"flex items-center gap-3 p-1 flex-shrink-0",
isCollapsed && "justify-center",
)}
>
<Avatar
text={user.shortName}
className={cn("flex-shrink-0", isCollapsed && "")}
size="sm"
/>
{!isCollapsed && (
<p
className={cn(
"text-sm font-medium text-gray-800 dark:text-gray-200",
"transition-opacity duration-300 ease-in-out truncate",
)}
>
{user.longName}
</p>
)}
</div>
{!isCollapsed && (
<div className="my-2 h-px bg-gray-200 dark:bg-gray-700 flex-shrink-0">
</div>
)}
<div
className={cn(
"flex flex-col gap-2 mt-1",
"transition-all duration-300 ease-in-out",
isCollapsed
? "opacity-0 max-w-0 h-0 invisible pointer-events-none"
: "opacity-100 max-w-xs h-auto visible",
)}
>
{deviceInfoItems.map((item) => {
const IconComponent = item.icon;
return (
<div
key={item.id}
className="flex items-center gap-2.5 text-sm"
>
{IconComponent && (
<IconComponent
size={16}
className="text-gray-500 dark:text-gray-400 w-4 flex-shrink-0"
/>
)}
{item.customComponent}
{item.id !== "battery" && (
<Subtle className="text-gray-600 dark:text-gray-300">
{item.label}: {item.value}
</Subtle>
)}
</div>
);
})}
</div>
{!isCollapsed && (
<div className="my-2 h-px bg-gray-200 dark:bg-gray-700 flex-shrink-0">
</div>
)}
<div
className={cn(
"flex flex-col gap-1 mt-1",
"transition-all duration-300 ease-in-out",
isCollapsed
? "opacity-0 max-w-0 h-0 invisible pointer-events-none"
: "opacity-100 max-w-xs visible",
)}
>
{actionButtons.map((buttonItem) => {
const Icon = buttonItem.icon;
if (buttonItem.render) {
return (
<Fragment key={buttonItem.id}>
{buttonItem.render()}
</Fragment>
);
}
return (
<Button
key={buttonItem.id}
variant="ghost"
aria-label={buttonItem.label}
onClick={buttonItem.onClick}
className={cn(
"group",
"flex w-full items-center justify-start text-sm p-1.5 rounded-md",
"gap-2.5",
"transition-colors duration-150",
!disableHover && "hover:bg-gray-100 dark:hover:bg-gray-700",
)}
>
<Icon
size={16}
className={cn(
"flex-shrink-0 w-4",
"text-gray-500 dark:text-gray-400",
"transition-colors duration-150",
!disableHover &&
"group-hover:text-gray-700 dark:group-hover:text-gray-200",
)}
/>
<Subtle
className={cn(
"text-sm",
"text-gray-600 dark:text-gray-300",
"transition-colors duration-150",
!disableHover &&
"group-hover:text-gray-800 dark:group-hover:text-gray-100",
)}
>
{buttonItem.label}
</Subtle>
</Button>
);
})}
</div>
</>
);
};

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,65 @@
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";
import { useTranslation } from "react-i18next";
export interface DeleteMessagesDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const DeleteMessagesDialog = ({
open,
onOpenChange,
}: DeleteMessagesDialogProps) => {
const { t } = useTranslation("dialog");
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" />
{t("deleteMessages.title")}
</DialogTitle>
<DialogDescription>
{t("deleteMessages.description")}
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-4">
<Button
variant="outline"
onClick={handleCloseDialog}
name="dismiss"
>
{t("button.dismiss")}
</Button>
<Button
variant="destructive"
onClick={() => {
deleteAllMessages();
handleCloseDialog();
}}
name="clearMessages"
>
{t("button.clearMessages")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -10,10 +10,13 @@ import {
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { Input } from "@components/UI/Input.tsx";
import { Label } from "@components/UI/Label.tsx";
import { Protobuf } from "@meshtastic/core";
import { useForm } from "react-hook-form";
import { GenericInput } from "@components/Form/FormInput.tsx";
import { useTranslation } from "react-i18next";
import { Label } from "../UI/Label.tsx";
import z from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
export interface User {
longName: string;
@@ -29,52 +32,116 @@ export const DeviceNameDialog = ({
open,
onOpenChange,
}: DeviceNameDialogProps) => {
const { hardware, nodes, connection } = useDevice();
const { t } = useTranslation("dialog");
const { hardware, getNode, connection } = useDevice();
const myNode = getNode(hardware.myNodeNum);
const myNode = nodes.get(hardware.myNodeNum);
const defaultValues = {
shortName: myNode?.user?.shortName ?? "",
longName: myNode?.user?.longName ?? "",
};
const { register, handleSubmit } = useForm<User>({
values: {
longName: myNode?.user?.longName ?? "Unknown",
shortName: myNode?.user?.shortName ?? "Unknown",
},
const deviceNameSchema = z.object({
longName: z
.string()
.min(1, t("deviceName.validation.longNameMin"))
.max(40, t("deviceName.validation.longNameMax")),
shortName: z
.string()
.min(2, t("deviceName.validation.shortNameMin"))
.max(4, t("deviceName.validation.shortNameMax")),
});
const { getValues, reset, control, handleSubmit } = useForm<User>({
values: defaultValues,
resolver: zodResolver(deviceNameSchema),
});
const onSubmit = handleSubmit((data) => {
connection?.setOwner(
create(Protobuf.Mesh.UserSchema, {
...myNode?.user,
...data,
}),
);
onOpenChange(false);
});
const handleReset = () => {
reset(defaultValues);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>Change Device Name</DialogTitle>
<DialogTitle>{t("deviceName.title")}</DialogTitle>
<DialogDescription>
The Device will restart once the config is saved.
{t("deviceName.description")}
</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">
{t("deviceName.longName")}
</Label>
<GenericInput
control={control}
field={{
name: "longName",
label: t("deviceName.longName"),
type: "text",
properties: {
className: "text-slate-900 dark:text-slate-200",
fieldLength: {
currentValueLength: getValues("longName").length,
max: 40,
min: 1,
showCharacterCount: true,
},
},
}}
/>
</form>
</div>
<DialogFooter>
<Button onClick={() => onSubmit()}>Save</Button>
</DialogFooter>
</div>
<div>
<Label htmlFor="shortName">
{t("deviceName.shortName")}
</Label>
<GenericInput
control={control}
field={{
name: "shortName",
label: t("deviceName.shortName"),
type: "text",
properties: {
fieldLength: {
currentValueLength: getValues("shortName").length,
max: 4,
min: 1,
showCharacterCount: true,
},
},
}}
/>
</div>
<DialogFooter>
<Button
type="button"
variant="destructive"
name="reset"
onClick={handleReset}
>
{t("button.reset")}
</Button>
<Button
type="submit"
name="save"
>
{t("button.save")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);

View File

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

View File

@@ -17,6 +17,7 @@ import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core";
import { toByteArray } from "base64-js";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
export interface ImportDialogProps {
open: boolean;
@@ -28,6 +29,7 @@ export const ImportDialog = ({
open,
onOpenChange,
}: ImportDialogProps) => {
const { t } = useTranslation("dialog");
const [importDialogInput, setImportDialogInput] = useState<string>("");
const [channelSet, setChannelSet] = useState<Protobuf.AppOnly.ChannelSet>();
const [validUrl, setValidUrl] = useState<boolean>(false);
@@ -44,14 +46,14 @@ export const ImportDialog = ({
channelsUrl.pathname !== "/e/") ||
!channelsUrl.hash
) {
throw "Invalid Meshtastic URL";
throw t("import.error.invalidUrl");
}
const encodedChannelConfig = channelsUrl.hash.substring(1);
const paddedString = encodedChannelConfig
.padEnd(
encodedChannelConfig.length +
((4 - (encodedChannelConfig.length % 4)) % 4),
((4 - (encodedChannelConfig.length % 4)) % 4),
"=",
)
.replace(/-/g, "+")
@@ -70,17 +72,19 @@ export const ImportDialog = ({
}, [importDialogInput]);
const apply = () => {
channelSet?.settings.map((ch: unknown, index: number) => {
connection?.setChannel(
create(Protobuf.Channel.ChannelSchema, {
index,
role: index === 0
? Protobuf.Channel.Channel_Role.PRIMARY
: Protobuf.Channel.Channel_Role.SECONDARY,
settings: ch,
}),
);
});
channelSet?.settings.map(
(ch: Protobuf.Channel.ChannelSettings, index: number) => {
connection?.setChannel(
create(Protobuf.Channel.ChannelSchema, {
index,
role: index === 0
? Protobuf.Channel.Channel_Role.PRIMARY
: Protobuf.Channel.Channel_Role.SECONDARY,
settings: ch,
}),
);
},
);
if (channelSet?.loraConfig) {
connection?.setConfig(
@@ -99,17 +103,16 @@ export const ImportDialog = ({
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>Import Channel Set</DialogTitle>
<DialogTitle>{t("import.title")}</DialogTitle>
<DialogDescription>
The current LoRa configuration will be overridden.
{t("import.description")}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
<Label>Channel Set/QR Code URL</Label>
<Label>{t("import.channelSetUrl")}</Label>
<Input
value={importDialogInput}
suffix={validUrl ? "✅" : "❌"}
className="dark:text-slate-900"
onChange={(e) => {
setImportDialogInput(e.target.value);
}}
@@ -118,7 +121,7 @@ export const ImportDialog = ({
<div className="flex flex-col gap-3">
<div className="flex w-full gap-2">
<div className="w-36">
<Label>Use Preset?</Label>
<Label>{t("import.usePreset")}</Label>
<Switch
disabled
checked={channelSet?.loraConfig?.usePreset ?? true}
@@ -145,7 +148,7 @@ export const ImportDialog = ({
}
<span className="text-md block font-medium text-text-primary">
Channels:
{t("import.channels")}
</span>
<div className="flex w-40 flex-col gap-1">
{channelSet?.settings.map((channel) => (
@@ -153,7 +156,7 @@ export const ImportDialog = ({
<Label>
{channel.name.length
? channel.name
: `Channel: ${channel.id}`}
: `${t("import.channelPrefix")}${channel.id}`}
</Label>
<Checkbox key={channel.id} />
</div>
@@ -163,8 +166,8 @@ export const ImportDialog = ({
)}
</div>
<DialogFooter>
<Button onClick={apply} disabled={!validUrl}>
Apply
<Button onClick={apply} disabled={!validUrl} name="apply">
{t("button.apply")}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -1,4 +1,4 @@
import { useDevice } from "../../core/stores/deviceStore.ts";
import { useDevice } from "@core/stores/deviceStore.ts";
import {
Dialog,
DialogClose,
@@ -9,9 +9,10 @@ import {
} from "../UI/Dialog.tsx";
import type { Protobuf, Types } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { useTranslation } from "react-i18next";
export interface LocationResponseDialogProps {
location: Types.PacketMetadata<Protobuf.Mesh.location> | undefined;
location: Types.PacketMetadata<Protobuf.Mesh.Position> | undefined;
open: boolean;
onOpenChange: () => void;
}
@@ -21,40 +22,70 @@ export const LocationResponseDialog = ({
open,
onOpenChange,
}: LocationResponseDialogProps) => {
const { nodes } = useDevice();
const { t } = useTranslation("dialog");
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");
(from ? `!${numberToHexUnpadded(from?.num)}` : t("unknown.shortName"));
const shortName = from?.user?.shortName ??
(from ? `${numberToHexUnpadded(from?.num).substring(0, 4)}` : "UNK");
(from
? `${numberToHexUnpadded(from?.num).substring(0, 4)}`
: t("unknown.shortName"));
const position = location?.data;
const hasCoordinates = position &&
typeof position.latitudeI === "number" &&
typeof position.longitudeI === "number" &&
typeof position.altitude === "number";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>{`Location: ${longName} (${shortName})`}</DialogTitle>
<DialogTitle>
{t("locationResponse.title", {
identifier: `${longName} (${shortName})`,
})}
</DialogTitle>
</DialogHeader>
<DialogDescription>
<div className="ml-5 flex">
<span className="ml-4 border-l-2 border-l-backgroundPrimary pl-2 text-textPrimary">
<p>
Coordinates:{" "}
<a
className="text-blue-500 dark:text-blue-400"
href={`https://www.openstreetmap.org/?mlat=${location?.data.latitudeI / 1e7
}&mlon=${location?.data.longitudeI / 1e7}&layers=N`}
target="_blank"
rel="noreferrer"
>
{location?.data.latitudeI / 1e7},{" "}
{location?.data.longitudeI / 1e7}
</a>
{hasCoordinates
? (
<div className="ml-5 flex">
<span className="ml-4 border-l-2 border-l-backgroundPrimary pl-2 text-textPrimary">
<p>
{t("locationResponse.coordinates")}
<a
className="text-blue-500 dark:text-blue-400"
href={`https://www.openstreetmap.org/?mlat=${
position.latitudeI ?? 0 / 1e7
}&mlon=${position.longitudeI ?? 0 / 1e7}&layers=N`}
target="_blank"
rel="noreferrer"
>
{" "}
{position.latitudeI ?? 0 / 1e7},{" "}
{position.longitudeI ?? 0 / 1e7}
</a>
</p>
<p>
{t("locationResponse.altitude")} {position.altitude}
{(position.altitude ?? 0) < 1
? t("unit.meter.one")
: t("unit.meter.plural")}
</p>
</span>
</div>
)
: (
// Optional: Show a message if coordinates are not available
<p className="text-textPrimary">
{t("locationResponse.noCoordinates")}
</p>
<p>Altitude: {location?.data.altitude}m</p>
</span>
</div>
)}
</DialogDescription>
</DialogContent>
</Dialog>

View File

@@ -0,0 +1,72 @@
import { Button } from "@components/UI/Button.tsx";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { Trans, useTranslation } from "react-i18next";
import { Checkbox } from "@components/UI/Checkbox/index.tsx";
import { useState } from "react";
export interface ManagedModeDialogProps {
open: boolean;
onOpenChange: () => void;
onSubmit: () => void;
}
export const ManagedModeDialog = ({
open,
onOpenChange,
onSubmit,
}: ManagedModeDialogProps) => {
const { t } = useTranslation("dialog");
const [confirmState, setConfirmState] = useState(false);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>{t("managedMode.title")}</DialogTitle>
<DialogDescription>
<Trans
i18nKey="managedMode.description"
components={{
"bold": <p className="font-bold inline" />,
}}
/>
</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-2">
<Checkbox
id="managedMode"
checked={confirmState}
onChange={() => setConfirmState(!confirmState)}
name="confirmUnderstanding"
>
<p className="dark:text-white pt-1">
{t("managedMode.confirmUnderstanding")}
</p>
</Checkbox>
</div>
<DialogFooter>
<Button
variant="destructive"
name="regenerate"
disabled={!confirmState}
onClick={() => {
setConfirmState(false);
onSubmit();
}}
>
{t("button.confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,7 +1,7 @@
import {
type BrowserFeature,
useBrowserFeatureDetection,
} from "../../core/hooks/useBrowserFeatureDetection.ts";
} from "@core/hooks/useBrowserFeatureDetection.ts";
import { BLE } from "@components/PageComponents/Connect/BLE.tsx";
import { HTTP } from "@components/PageComponents/Connect/HTTP.tsx";
import { Serial } from "@components/PageComponents/Connect/Serial.tsx";
@@ -18,16 +18,16 @@ import {
TabsList,
TabsTrigger,
} from "@components/UI/Tabs.tsx";
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
import { AlertCircle } from "lucide-react";
import { Trans, useTranslation } from "react-i18next";
import { Link } from "../UI/Typography/Link.tsx";
import { Fragment } from "react/jsx-runtime";
export interface TabElementProps {
closeDialog: () => void;
}
export interface TabManifest {
id: "HTTP" | "BLE" | "Serial";
label: string;
element: React.FC<TabElementProps>;
isDisabled: boolean;
@@ -40,23 +40,28 @@ export interface NewDeviceProps {
interface FeatureErrorProps {
missingFeatures: BrowserFeature[];
tabId: "HTTP" | "BLE" | "Serial";
}
const links: { [key: string]: string } = {
"Web Bluetooth":
"https://developer.mozilla.org/en-US/docs/Web/API/Web_Bluetooth_API#browser_compatibility",
"Web Serial":
"https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API#browser_compatibility",
"Secure Context":
"https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts",
const errors: Record<BrowserFeature, { href: string; i18nKey: string }> = {
"Web Bluetooth": {
href:
"https://developer.mozilla.org/en-US/docs/Web/API/Web_Bluetooth_API#browser_compatibility",
i18nKey: "newDeviceDialog.validation.requiresWebBluetooth",
},
"Web Serial": {
href:
"https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API#browser_compatibility",
i18nKey: "newDeviceDialog.validation.requiresWebSerial",
},
"Secure Context": {
href:
"https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts",
i18nKey: "newDeviceDialog.validation.requiresSecureContext",
},
};
const listFormatter = new Intl.ListFormat("en", {
style: "long",
type: "disjunction",
});
const ErrorMessage = ({ missingFeatures }: FeatureErrorProps) => {
const ErrorMessage = ({ missingFeatures, tabId }: FeatureErrorProps) => {
if (missingFeatures.length === 0) return null;
const browserFeatures = missingFeatures.filter(
@@ -64,46 +69,51 @@ const ErrorMessage = ({ missingFeatures }: FeatureErrorProps) => {
);
const needsSecureContext = missingFeatures.includes("Secure Context");
const formatFeatureList = (features: string[]) => {
const parts = listFormatter.formatToParts(features);
return parts.map((part) => {
if (part.type === "element") {
return (
<Link key={part.value} href={links[part.value]}>
{part.value}
</Link>
);
}
return <Fragment key={part.value}>{part.value}</Fragment>;
});
};
const needsFeature =
(tabId === "BLE" && browserFeatures.includes("Web Bluetooth"))
? "Web Bluetooth"
: (tabId === "Serial" && browserFeatures.includes("Web Serial"))
? "Web Serial"
: undefined;
return (
<Subtle className="flex flex-col items-start gap-2 bg-red-500 p-4 rounded-md">
<div className="flex flex-col items-start gap-2 bg-red-500 p-4 rounded-md text-sm text-slate-500 dark:text-slate-400">
<div className="flex items-center gap-2 w-full">
<AlertCircle size={40} className="mr-2 shrink-0 text-white" />
<div className="flex flex-col gap-3">
<p className="text-sm text-white">
{browserFeatures.length > 0 && (
<>
This connection type requires{" "}
{formatFeatureList(browserFeatures)}. Please use a
supported browser, like Chrome or Edge.
</>
<div className="text-sm text-white">
{needsFeature && (
<Trans
i18nKey={errors[needsFeature].i18nKey}
components={[
<Link
key="0"
href={errors[needsFeature].href}
className="underline hover:text-slate-200 text-white dark:text-white dark:hover:text-slate-300"
/>,
]}
/>
)}
{needsFeature && needsSecureContext && " "}
{needsSecureContext && (
<>
{browserFeatures.length > 0 && " Additionally, it"}
{browserFeatures.length === 0 && "This application"} requires a
{" "}
<Link href={links["Secure Context"]}>secure context</Link>.
Please connect using HTTPS or localhost.
</>
<Trans
i18nKey={browserFeatures.length > 0
? "newDeviceDialog.validation.additionallyRequiresSecureContext"
: "newDeviceDialog.validation.requiresSecureContext"}
components={{
"0": (
<Link
href={errors["Secure Context"].href}
className="underline hover:text-slate-200"
/>
),
}}
/>
)}
</p>
</div>
</div>
</div>
</Subtle>
</div>
);
};
@@ -111,22 +121,26 @@ export const NewDeviceDialog = ({
open,
onOpenChange,
}: NewDeviceProps) => {
const { t } = useTranslation("dialog");
const { unsupported } = useBrowserFeatureDetection();
const tabs: TabManifest[] = [
{
label: "HTTP",
id: "HTTP",
label: t("newDeviceDialog.tabHttp"),
element: HTTP,
isDisabled: false,
},
{
label: "Bluetooth",
id: "BLE",
label: t("newDeviceDialog.tabBluetooth"),
element: BLE,
isDisabled: unsupported.includes("Web Bluetooth") ||
unsupported.includes("Secure Context"),
},
{
label: "Serial",
id: "Serial",
label: t("newDeviceDialog.tabSerial"),
element: Serial,
isDisabled: unsupported.includes("Web Serial") ||
unsupported.includes("Secure Context"),
@@ -135,26 +149,35 @@ export const NewDeviceDialog = ({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogContent aria-describedby={undefined}>
<DialogClose />
<DialogHeader>
<DialogTitle>Connect New Device</DialogTitle>
<DialogTitle>{t("newDeviceDialog.title")}</DialogTitle>
</DialogHeader>
<Tabs defaultValue="HTTP">
<TabsList>
{tabs.map((tab) => (
<TabsTrigger key={tab.label} value={tab.label}>
<TabsTrigger key={tab.id} value={tab.id}>
{tab.label}
</TabsTrigger>
))}
</TabsList>
{tabs.map((tab) => (
<TabsContent key={tab.label} value={tab.label}>
<TabsContent key={tab.id} value={tab.id}>
<fieldset disabled={tab.isDisabled}>
{tab.isDisabled
? <ErrorMessage missingFeatures={unsupported} />
: null}
<tab.element closeDialog={() => onOpenChange(false)} />
{(tab.id !== "HTTP" &&
tab.isDisabled)
? (
<ErrorMessage
missingFeatures={unsupported}
tabId={tab.id}
/>
)
: (
<tab.element
closeDialog={() => onOpenChange(false)}
/>
)}
</fieldset>
</TabsContent>
))}

View File

@@ -1,73 +0,0 @@
import { describe, it, vi, expect, beforeEach, Mock } from "vitest";
import { render, screen } from "@testing-library/react";
import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useAppStore } from "@core/stores/appStore.ts";
vi.mock("@core/stores/deviceStore");
vi.mock("@core/stores/appStore");
describe("NodeDetailsDialog", () => {
const mockDevice = {
num: 1234,
user: {
longName: "Test Node",
shortName: "TN",
hwModel: 1,
role: 1,
},
lastHeard: 1697500000,
position: {
latitudeI: 450000000,
longitudeI: -750000000,
altitude: 200,
},
deviceMetrics: {
airUtilTx: 50.123,
channelUtilization: 75.456,
batteryLevel: 88.789,
voltage: 4.2,
uptimeSeconds: 3600,
},
};
beforeEach(() => {
// Reset mocks before each test
vi.resetAllMocks();
(useDevice as Mock).mockReturnValue({
nodes: new Map([[1234, mockDevice]]),
});
(useAppStore as unknown as Mock).mockReturnValue({
nodeNumDetails: 1234,
});
});
it("renders node details correctly", () => {
render(<NodeDetailsDialog open={true} onOpenChange={() => { }} />);
expect(screen.getByText(/Node Details for Test Node/i)).toBeInTheDocument();
expect(screen.getByText("Node Number: 1234")).toBeInTheDocument();
expect(screen.getByText(/Air TX utilization: 50.12%/i)).toBeInTheDocument();
expect(screen.getByText(/Channel utilization: 75.46%/i)).toBeInTheDocument();
expect(screen.getByText(/Battery level: 88.79%/i)).toBeInTheDocument();
expect(screen.getByText(/Voltage: 4.20V/i)).toBeInTheDocument();
expect(screen.getByText(/Uptime:/i)).toBeInTheDocument();
expect(screen.getByText(/Coordinates:/i)).toBeInTheDocument();
expect(screen.getByText("45, -75")).toBeInTheDocument();
expect(screen.getByText(/Altitude: 200m/i)).toBeInTheDocument();
expect(screen.getByText(/Role:/i)).toBeInTheDocument();
});
it("renders null if device is not found", () => {
(useDevice as Mock).mockReturnValue({
nodes: new Map(),
});
render(<NodeDetailsDialog open={true} onOpenChange={() => { }} />);
expect(screen.queryByText(/Node Details for/i)).not.toBeInTheDocument();
});
});

View File

@@ -1,5 +1,16 @@
import { useEffect, useState } from "react";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { DeviceImage } from "@components/generic/DeviceImage.tsx";
import { TimeAgo } from "@components/generic/TimeAgo.tsx";
import { Uptime } from "@components/generic/Uptime.tsx";
import { toast } from "@core/hooks/useToast.ts";
import { useFavoriteNode } from "@core/hooks/useFavoriteNode.ts";
import { useIgnoreNode } from "@core/hooks/useIgnoreNode.ts";
import { cn } from "@core/utils/cn.ts";
import {
Accordion,
AccordionContent,
@@ -14,11 +25,26 @@ import {
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { Protobuf } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { DeviceImage } from "@components/generic/DeviceImage.tsx";
import { TimeAgo } from "@components/generic/TimeAgo.tsx";
import { Uptime } from "@components/generic/Uptime.tsx";
import { Button } from "@components/UI/Button.tsx";
import {
BellIcon,
BellOffIcon,
MapPinnedIcon,
MessageSquareIcon,
StarIcon,
TrashIcon,
WaypointsIcon,
} from "lucide-react";
import {
Tooltip,
TooltipArrow,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@components/UI/Tooltip.tsx";
import { Separator } from "@components/UI/Seperator.tsx";
import { useTranslation } from "react-i18next";
import { useNavigate } from "@tanstack/react-router";
export interface NodeDetailsDialogProps {
open: boolean;
@@ -29,110 +55,287 @@ export const NodeDetailsDialog = ({
open,
onOpenChange,
}: NodeDetailsDialogProps) => {
const { nodes } = useDevice();
const { nodeNumDetails } = useAppStore();
const { t } = useTranslation("dialog");
const { setDialogOpen, connection, getNode } = useDevice();
const navigate = useNavigate();
const { setNodeNumToBeRemoved, nodeNumDetails } = useAppStore();
const { updateFavorite } = useFavoriteNode();
const { updateIgnored } = useIgnoreNode();
const device = nodes.get(nodeNumDetails);
const node = getNode(nodeNumDetails);
if (!device) return null;
const [isFavoriteState, setIsFavoriteState] = useState<boolean>(
node?.isFavorite ?? false,
);
const [isIgnoredState, setIsIgnoredState] = useState<boolean>(
node?.isIgnored ?? false,
);
useEffect(() => {
if (!node) return;
setIsFavoriteState(node?.isFavorite);
setIsIgnoredState(node?.isIgnored);
}, [node]);
if (!node) return;
function handleDirectMessage() {
if (!node) return;
navigate({ to: `/messages/direct/${node.num}` });
setDialogOpen("nodeDetails", false);
}
function handleRequestPosition() {
if (!node) return;
toast({
title: t("toast.requestingPosition.title", { ns: "ui" }),
});
connection?.requestPosition(node.num).then(() =>
toast({
title: t("toast.positionRequestSent.title", { ns: "ui" }),
})
);
onOpenChange(false);
}
function handleTraceroute() {
if (!node) return;
toast({
title: t("toast.sendingTraceroute.title", { ns: "ui" }),
});
connection?.traceRoute(node.num).then(() =>
toast({
title: t("toast.tracerouteSent.title", { ns: "ui" }),
})
);
onOpenChange(false);
}
function handleNodeRemove() {
if (!node) return;
setNodeNumToBeRemoved(node?.num);
setDialogOpen("nodeRemoval", true);
onOpenChange(false);
}
function handleToggleFavorite() {
if (!node) return;
updateFavorite({ nodeNum: node.num, isFavorite: !isFavoriteState });
setIsFavoriteState(!isFavoriteState);
}
function handleToggleIgnored() {
if (!node) return;
updateIgnored({ nodeNum: node.num, isIgnored: !isIgnoredState });
setIsIgnoredState(!isIgnoredState);
}
const deviceMetricsMap = [
{
key: "airUtilTx",
label: "Air TX utilization",
value: device.deviceMetrics?.airUtilTx,
label: t("nodeDetails.airTxUtilization"),
value: node.deviceMetrics?.airUtilTx,
format: (val: number) => `${val.toFixed(2)}%`,
},
{
key: "channelUtilization",
label: "Channel utilization",
value: device.deviceMetrics?.channelUtilization,
label: t("nodeDetails.channelUtilization"),
value: node.deviceMetrics?.channelUtilization,
format: (val: number) => `${val.toFixed(2)}%`,
},
{
key: "batteryLevel",
label: "Battery level",
value: device.deviceMetrics?.batteryLevel,
label: t("nodeDetails.batteryLevel"),
value: node.deviceMetrics?.batteryLevel,
format: (val: number) => `${val.toFixed(2)}%`,
},
{
key: "voltage",
label: "Voltage",
value: device.deviceMetrics?.voltage,
label: t("nodeDetails.voltage"),
value: node.deviceMetrics?.voltage,
format: (val: number) => `${val.toFixed(2)}V`,
},
];
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent >
<DialogContent aria-describedby={undefined}>
<DialogClose />
<DialogHeader>
<DialogTitle>
Node Details for {device.user?.longName ?? "UNKNOWN"} (
{device.user?.shortName ?? "UNK"})
{t("nodeDetails.title", {
identifier: `${node.user?.longName ?? t("unknown.shortName")} (${
node.user?.shortName ?? t("unknown.shortName")
})`,
})}
</DialogTitle>
</DialogHeader>
<DialogFooter>
<div className="w-full">
<div className="flex flex-col">
<DeviceImage
className="w-32 h-32 mx-auto rounded-lg border-4 border-slate-200 dark:border-slate-800"
deviceType={
Protobuf.Mesh.HardwareModel[device.user?.hwModel ?? 0]
}
/>
<div className="bg-slate-100 text-slate-900 dark:text-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
<p className="text-lg font-semibold">Details:</p>
<p>
Hardware:{" "}
{Protobuf.Mesh.HardwareModel[device.user?.hwModel ?? 0]}
</p>
<p>Node Number: {device.num}</p>
<p>Node Hex: !{numberToHexUnpadded(device.num)}</p>
<p>
Role:{" "}
{
Protobuf.Config.Config_DeviceConfig_Role[
device.user?.role ?? 0
]
}
</p>
<p>
Last Heard:{" "}
{device.lastHeard === 0 ? "Never" : <TimeAgo timestamp={device.lastHeard * 1000} />}
<div className="flex flex-row flex-wrap space-y-1">
<Button
className="mr-1"
name="message"
onClick={handleDirectMessage}
>
<MessageSquareIcon className="mr-2" />
{t("nodeDetails.message")}
</Button>
<Button
className="mr-1"
name="traceRoute"
onClick={handleTraceroute}
>
<WaypointsIcon className="mr-2" />
{t("nodeDetails.traceRoute")}
</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
? t("nodeDetails.unignoreNode")
: t("nodeDetails.ignoreNode")}
<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">
{t("nodeDetails.removeNode")}
<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">
{t("nodeDetails.details")}
</p>
<p>{t("nodeDetails.nodeNumber")}{node.num}</p>
<p>
{t("nodeDetails.nodeHexPrefix")}
{numberToHexUnpadded(node.num)}
</p>
<p>
{t("nodeDetails.role")}
{Protobuf.Config.Config_DeviceConfig_Role[
node.user?.role ?? 0
].replace(/_/g, " ")}
</p>
<p>
{t("nodeDetails.lastHeard")}
{node.lastHeard === 0
? t("nodesTable.lastHeardStatus.never", { ns: "nodes" })
: <TimeAgo timestamp={node.lastHeard * 1000} />}
</p>
<p>
{t("nodeDetails.hardware")}
{(Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0] ??
t("unknown.shortName"))
.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">
{t("nodeDetails.position")}
</p>
{node.position
? (
<>
{node.position.latitudeI &&
node.position.longitudeI && (
<p>
{t("locationResponse.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>
{t("locationResponse.altitude")}
{node.position.altitude}
{t("unit.meter.one")}
</p>
)}
</>
)
: <p>{t("unknown.shortName")}</p>}
<Button
onClick={handleRequestPosition}
name="requestPosition"
className="mt-2"
>
<MapPinnedIcon className="mr-2" />
{t("nodeDetails.requestPosition")}
</Button>
</div>
{device.position && (
{node.deviceMetrics && (
<div className="text-slate-900 dark:text-slate-100 bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
<p className="text-lg font-semibold">Position:</p>
{device.position.latitudeI && device.position.longitudeI && (
<p>
Coordinates:{" "}
<a
className="text-blue-500 dark:text-blue-400"
href={`https://www.openstreetmap.org/?mlat=${device.position.latitudeI / 1e7
}&mlon=${device.position.longitudeI / 1e7
}&layers=N`}
target="_blank"
rel="noreferrer"
>
{device.position.latitudeI / 1e7},{" "}
{device.position.longitudeI / 1e7}
</a>
</p>
)}
{device.position.altitude && (
<p>Altitude: {device.position.altitude}m</p>
)}
</div>
)}
{device.deviceMetrics && (
<div className="text-slate-900 dark:text-slate-100 bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
<p className="text-lg font-semibold text-slate-900 dark:text-slate-50">
Device Metrics:
<p className="text-lg font-semibold text-slate-900 dark:text-slate-100">
{t("nodeDetails.deviceMetrics")}
</p>
{deviceMetricsMap.map(
(metric) =>
@@ -140,17 +343,16 @@ export const NodeDetailsDialog = ({
<p key={metric.key}>
{metric.label}: {metric.format(metric.value)}
</p>
)
),
)}
{device.deviceMetrics.uptimeSeconds && (
{node.deviceMetrics.uptimeSeconds && (
<p>
Uptime:{" "}
<Uptime seconds={device.deviceMetrics.uptimeSeconds} />
{t("nodeDetails.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">
@@ -158,12 +360,12 @@ export const NodeDetailsDialog = ({
<AccordionItem className="AccordionItem" value="item-1">
<AccordionTrigger>
<p className="text-lg font-semibold text-slate-900 dark:text-slate-50">
All Raw Metrics:
{t("nodeDetails.allRawMetrics")}
</p>
</AccordionTrigger>
<AccordionContent className="overflow-x-scroll">
<pre className="text-xs w-full">
{JSON.stringify(device, null, 2)}
{JSON.stringify(node, null, 2)}
</pre>
</AccordionContent>
</AccordionItem>

View File

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

View File

@@ -12,6 +12,7 @@ import {
import { fromByteArray } from "base64-js";
import { DownloadIcon, PrinterIcon } from "lucide-react";
import React from "react";
import { useTranslation } from "react-i18next";
export interface PkiBackupDialogProps {
open: boolean;
@@ -22,7 +23,8 @@ export const PkiBackupDialog = ({
open,
onOpenChange,
}: PkiBackupDialogProps) => {
const { config, setDialogOpen } = useDevice();
const { t } = useTranslation("dialog");
const { config, setDialogOpen, getMyNode } = useDevice();
const privateKey = config.security?.privateKey;
const publicKey = config.security?.publicKey;
@@ -46,7 +48,12 @@ export const PkiBackupDialog = ({
printWindow.document.write(`
<html>
<head>
<title>=== MESHTASTIC KEYS ===</title>
<title>${
t("pkiBackup.header", {
shortName: getMyNode()?.user?.shortName ?? t("unknown.shortName"),
longName: getMyNode()?.user?.longName ?? t("unknown.longName"),
})
}</title>
<style>
body { font-family: Arial, sans-serif; padding: 20px; }
h1 { font-size: 18px; }
@@ -54,14 +61,18 @@ export const PkiBackupDialog = ({
</style>
</head>
<body>
<h1>=== MESHTASTIC KEYS ===</h1>
<br>
<h2>Public Key:</h2>
<h1>${
t("pkiBackup.header", {
shortName: getMyNode()?.user?.shortName ?? t("unknown.shortName"),
longName: getMyNode()?.user?.longName ?? t("unknown.longName"),
})
}</h1>
<h3>${t("pkiBackup.secureBackup")}</h3>
<h3>${t("pkiBackup.publicKey")}</h3>
<p>${decodeKeyData(publicKey)}</p>
<h2>Private Key:</h2>
<h3>${t("pkiBackup.privateKey")}</h3>
<p>${decodeKeyData(privateKey)}</p>
<br>
<p>=== END OF KEYS ===</p>
<p>${t("pkiBackup.footer")}</p>
</body>
</html>
`);
@@ -69,7 +80,7 @@ export const PkiBackupDialog = ({
printWindow.print();
closeDialog();
}
}, [decodeKeyData, privateKey, publicKey, closeDialog]);
}, [decodeKeyData, privateKey, publicKey, closeDialog, t]);
const createDownloadKeyFile = React.useCallback(() => {
if (!privateKey || !publicKey) return;
@@ -78,12 +89,12 @@ export const PkiBackupDialog = ({
const decodedPublicKey = decodeKeyData(publicKey);
const formattedContent = [
"=== MESHTASTIC KEYS ===\n\n",
"Private Key:\n",
`${t("pkiBackup.header")}\n\n`,
`${t("pkiBackup.privateKey")}\n`,
decodedPrivateKey,
"\n\nPublic Key:\n",
`\n\n${t("pkiBackup.publicKey")}\n`,
decodedPublicKey,
"\n\n=== END OF KEYS ===",
`\n\n${t("pkiBackup.footer")}`,
].join("");
const blob = new Blob([formattedContent], { type: "text/plain" });
@@ -91,43 +102,47 @@ export const PkiBackupDialog = ({
const link = document.createElement("a");
link.href = url;
link.download = "meshtastic_keys.txt";
link.download = t("pkiBackup.fileName", {
shortName: getMyNode()?.user?.shortName ?? t("unknown.shortName"),
longName: getMyNode()?.user?.longName ?? t("unknown.longName"),
});
link.style.display = "none";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
closeDialog();
URL.revokeObjectURL(url);
}, [decodeKeyData, privateKey, publicKey, closeDialog]);
}, [decodeKeyData, privateKey, publicKey, closeDialog, t]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>Backup Keys</DialogTitle>
<DialogTitle>{t("pkiBackup.title")}</DialogTitle>
<DialogDescription>
Its important to backup your public and private keys and store your
backup securely!
{t("pkiBackup.secureBackup")}
</DialogDescription>
<DialogDescription>
<span className="font-bold break-before-auto">
If you lose your keys, you will need to reset your device.
{t("pkiBackup.loseKeysWarning")}
</span>
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-6">
<Button
variant="default"
name="download"
onClick={() => createDownloadKeyFile()}
className=""
>
<DownloadIcon size={20} className="mr-2" />
Download
{t("button.download")}
</Button>
<Button variant="default" onClick={() => renderPrintWindow()}>
<PrinterIcon size={20} className="mr-2" />
Print
{t("button.print")}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -8,31 +8,53 @@ import {
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { useTranslation } from "react-i18next";
export interface PkiRegenerateDialogProps {
text: {
title: string;
description: string;
button: string;
};
open: boolean;
onOpenChange: () => void;
onSubmit: () => void;
}
export const PkiRegenerateDialog = ({
text = {
title: "",
description: "",
button: "",
},
open,
onOpenChange,
onSubmit,
}: PkiRegenerateDialogProps) => {
const { t } = useTranslation("dialog");
const dialogText = {
title: text.title || t("pkiRegenerate.title"),
description: text.description ||
t("pkiRegenerate.description"),
button: text.button || t("button.regenerate"),
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>Regenerate Key pair?</DialogTitle>
<DialogTitle>{dialogText.title}</DialogTitle>
<DialogDescription>
Are you sure you want to regenerate key pair?
{dialogText.description}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="destructive" onClick={() => onSubmit()}>
Regenerate
<Button
variant="destructive"
name="regenerate"
onClick={() => onSubmit()}
>
{dialogText.button}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -13,9 +13,9 @@ import { Input } from "@components/UI/Input.tsx";
import { Label } from "@components/UI/Label.tsx";
import { Protobuf, type Types } from "@meshtastic/core";
import { fromByteArray } from "base64-js";
import { ClipboardIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { QRCode } from "react-qrcode-logo";
import { useTranslation } from "react-i18next";
export interface QRDialogProps {
open: boolean;
@@ -30,6 +30,7 @@ export const QRDialog = ({
loraConfig,
channels,
}: QRDialogProps) => {
const { t } = useTranslation("dialog");
const [selectedChannels, setSelectedChannels] = useState<number[]>([0]);
const [qrCodeUrl, setQrCodeUrl] = useState<string>("");
const [qrCodeAdd, setQrCodeAdd] = useState<boolean>();
@@ -65,9 +66,9 @@ export const QRDialog = ({
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>Generate QR Code</DialogTitle>
<DialogTitle>{t("qr.title")}</DialogTitle>
<DialogDescription>
The current LoRa configuration will also be shared.
{t("qr.description")}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
@@ -79,13 +80,18 @@ export const QRDialog = ({
{channel.settings?.name.length
? channel.settings.name
: channel.role === Protobuf.Channel.Channel_Role.PRIMARY
? "Primary"
: `Channel: ${channel.index}`}
? t("page.broadcastLabel", { ns: "channels" })
: `${
t("page.channelIndex", {
ns: "channels",
index: channel.index,
})
}${channel.index}`}
</Label>
<Checkbox
key={channel.index}
checked={selectedChannels.includes(channel.index)}
onCheckedChange={() => {
onChange={() => {
if (selectedChannels.includes(channel.index)) {
setSelectedChannels(
selectedChannels.filter((c) =>
@@ -108,38 +114,35 @@ export const QRDialog = ({
<div className="flex justify-center">
<button
type="button"
className={`border-slate-900 border-t border-l border-b rounded-l h-10 px-7 py-2 text-sm font-medium focus:outline-hidden focus:ring-2 focus:ring-offset-2 ${qrCodeAdd
? "focus:ring-green-800 bg-green-800 text-white"
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
}`}
className={`border-slate-900 border-t border-l border-b rounded-l h-10 px-7 py-2 text-sm font-medium focus:outline-hidden focus:ring-2 focus:ring-offset-2 ${
qrCodeAdd
? "focus:ring-green-800 bg-green-800 text-white"
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
}`}
name="addChannels"
onClick={() => setQrCodeAdd(true)}
>
Add Channels
{t("qr.addChannels")}
</button>
<button
type="button"
className={`border-slate-900 border-t border-r border-b rounded-r h-10 px-4 py-2 text-sm font-medium focus:outline-hidden focus:ring-2 focus:ring-offset-2 ${!qrCodeAdd
? "focus:ring-green-800 bg-green-800 text-white"
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
}`}
className={`border-slate-900 border-t border-r border-b rounded-r h-10 px-4 py-2 text-sm font-medium focus:outline-hidden focus:ring-2 focus:ring-offset-2 ${
!qrCodeAdd
? "focus:ring-green-800 bg-green-800 text-white"
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
}`}
name="replaceChannels"
onClick={() => setQrCodeAdd(false)}
>
Replace Channels
{t("qr.replaceChannels")}
</button>
</div>
</div>
<DialogFooter>
<Label>Sharable URL</Label>
<Label>{t("qr.sharableUrl")}</Label>
<Input
value={qrCodeUrl}
disabled
className="dark:text-slate-900"
action={{
icon: ClipboardIcon,
onClick() {
void navigator.clipboard.writeText(qrCodeUrl);
},
}}
/>
</DialogFooter>
</DialogContent>

View File

@@ -9,7 +9,8 @@ import {
} from "@components/UI/Dialog.tsx";
import { Input } from "@components/UI/Input.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { ClockIcon, RefreshCwIcon } from "lucide-react";
import { RefreshCwIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useState } from "react";
export interface RebootDialogProps {
@@ -21,6 +22,7 @@ export const RebootDialog = ({
open,
onOpenChange,
}: RebootDialogProps) => {
const { t } = useTranslation("dialog");
const { connection } = useDevice();
const [time, setTime] = useState<number>(5);
@@ -30,9 +32,11 @@ export const RebootDialog = ({
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>Schedule Reboot</DialogTitle>
<DialogTitle>
{t("reboot.title")}
</DialogTitle>
<DialogDescription>
Reboot the connected node after x minutes.
{t("reboot.description")}
</DialogDescription>
</DialogHeader>
<div className="flex gap-2 p-4">
@@ -41,21 +45,16 @@ export const RebootDialog = ({
className="dark:text-slate-900"
value={time}
onChange={(e) => setTime(Number.parseInt(e.target.value))}
action={{
icon: ClockIcon,
onClick() {
connection?.reboot(time * 60).then(() => onOpenChange(false));
},
}}
/>
<Button
className="w-24"
name="now"
onClick={() => {
connection?.reboot(2).then(() => onOpenChange(false));
}}
>
<RefreshCwIcon className="mr-2" size={16} />
Now
{t("button.now")}
</Button>
</div>
</DialogContent>

View File

@@ -1,48 +1,69 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { RebootOTADialog } from './RebootOTADialog.tsx';
import { ReactNode } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { RebootOTADialog } from "./RebootOTADialog.tsx";
import {
ButtonHTMLAttributes,
ClassAttributes,
InputHTMLAttributes,
ReactNode,
} from "react";
import { JSX } from "react/jsx-runtime";
const rebootOtaMock = vi.fn();
let mockConnection: { rebootOta: (delay: number) => void } | undefined = {
rebootOta: rebootOtaMock,
};
vi.mock('@core/stores/deviceStore.ts', () => ({
vi.mock("@core/stores/deviceStore.ts", () => ({
useDevice: () => ({
connection: mockConnection,
}),
}));
vi.mock('@components/UI/Button.tsx', async () => {
const actual = await vi.importActual('@components/UI/Button.tsx');
vi.mock("@components/UI/Button.tsx", async () => {
const actual = await vi.importActual("@components/UI/Button.tsx");
return {
...actual,
Button: (props: any) => <button {...props} />,
Button: (
props:
& JSX.IntrinsicAttributes
& ClassAttributes<HTMLButtonElement>
& ButtonHTMLAttributes<HTMLButtonElement>,
) => <button {...props} />,
};
});
vi.mock('@components/UI/Input.tsx', async () => {
const actual = await vi.importActual('@components/UI/Input.tsx');
vi.mock("@components/UI/Input.tsx", async () => {
const actual = await vi.importActual("@components/UI/Input.tsx");
return {
...actual,
Input: (props: any) => <input {...props} />,
Input: (
props:
& JSX.IntrinsicAttributes
& ClassAttributes<HTMLInputElement>
& InputHTMLAttributes<HTMLInputElement>,
) => <input {...props} />,
};
});
vi.mock('@components/UI/Dialog.tsx', () => {
vi.mock("@components/UI/Dialog.tsx", () => {
return {
Dialog: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogContent: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
DialogHeader: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
DialogTitle: ({ children }: { children: ReactNode }) => <h1>{children}</h1>,
DialogDescription: ({ children }: { children: ReactNode }) => <p>{children}</p>,
DialogDescription: ({ children }: { children: ReactNode }) => (
<p>{children}</p>
),
DialogClose: () => null,
};
});
describe('RebootOTADialog', () => {
describe("RebootOTADialog", () => {
beforeEach(() => {
vi.useFakeTimers();
rebootOtaMock.mockClear();
@@ -52,22 +73,23 @@ describe('RebootOTADialog', () => {
vi.useRealTimers();
});
it('renders dialog with default input value', () => {
render(<RebootOTADialog open={true} onOpenChange={() => { }} />);
it("renders dialog with default input value", () => {
render(<RebootOTADialog open onOpenChange={() => {}} />);
expect(screen.getByPlaceholderText(/enter delay/i)).toHaveValue(5);
expect(screen.getByText(/schedule reboot/i)).toBeInTheDocument();
expect(screen.getByRole("heading", { name: /schedule reboot/i, level: 1 }))
.toBeInTheDocument();
expect(screen.getByText(/reboot to ota mode now/i)).toBeInTheDocument();
});
it('schedules a reboot with delay and calls rebootOta', async () => {
it("schedules a reboot with delay and calls rebootOta", async () => {
const onOpenChangeMock = vi.fn();
render(<RebootOTADialog open={true} onOpenChange={onOpenChangeMock} />);
render(<RebootOTADialog open onOpenChange={onOpenChangeMock} />);
fireEvent.change(screen.getByPlaceholderText(/enter delay/i), {
target: { value: '3' },
target: { value: "3" },
});
fireEvent.click(screen.getByText(/schedule reboot/i));
fireEvent.click(screen.getByTestId("scheduleRebootBtn"));
expect(screen.getByText(/reboot has been scheduled/i)).toBeInTheDocument();
@@ -79,9 +101,9 @@ describe('RebootOTADialog', () => {
});
});
it('triggers an instant reboot', async () => {
it("triggers an instant reboot", async () => {
const onOpenChangeMock = vi.fn();
render(<RebootOTADialog open={true} onOpenChange={onOpenChangeMock} />);
render(<RebootOTADialog open onOpenChange={onOpenChangeMock} />);
fireEvent.click(screen.getByText(/reboot to ota mode now/i));
@@ -91,15 +113,14 @@ describe('RebootOTADialog', () => {
});
});
it('does not call reboot if connection is undefined', async () => {
it("does not call reboot if connection is undefined", async () => {
const onOpenChangeMock = vi.fn();
// simulate no connection
mockConnection = undefined;
render(<RebootOTADialog open={true} onOpenChange={onOpenChangeMock} />);
render(<RebootOTADialog open onOpenChange={onOpenChangeMock} />);
fireEvent.click(screen.getByText(/schedule reboot/i));
fireEvent.click(screen.getByTestId("scheduleRebootBtn"));
vi.advanceTimersByTime(5000);
await waitFor(() => {
@@ -107,8 +128,6 @@ describe('RebootOTADialog', () => {
expect(onOpenChangeMock).not.toHaveBeenCalled();
});
// reset connection for other tests
mockConnection = { rebootOta: rebootOtaMock };
});
});

View File

@@ -11,6 +11,7 @@ import {
} from "@components/UI/Dialog.tsx";
import { Input } from "@components/UI/Input.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useTranslation } from "react-i18next";
export interface RebootOTADialogProps {
open: boolean;
@@ -19,7 +20,10 @@ export interface RebootOTADialogProps {
const DEFAULT_REBOOT_DELAY = 5; // seconds
export const RebootOTADialog = ({ open, onOpenChange }: RebootOTADialogProps) => {
export const RebootOTADialog = (
{ open, onOpenChange }: RebootOTADialogProps,
) => {
const { t } = useTranslation("dialog");
const { connection } = useDevice();
const [time, setTime] = useState<number>(DEFAULT_REBOOT_DELAY);
const [isScheduled, setIsScheduled] = useState(false);
@@ -28,8 +32,8 @@ export const RebootOTADialog = ({ open, onOpenChange }: RebootOTADialogProps) =>
const handleSetTime = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.validity.valid) {
e.preventDefault();
return
};
return;
}
const val = e.target.value;
setInputValue(val);
@@ -48,7 +52,6 @@ export const RebootOTADialog = ({ open, onOpenChange }: RebootOTADialogProps) =>
await new Promise<void>((resolve) => {
setTimeout(() => {
console.log("Rebooting...");
resolve();
}, delay * 1000);
}).finally(() => {
@@ -71,9 +74,11 @@ export const RebootOTADialog = ({ open, onOpenChange }: RebootOTADialogProps) =>
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>Reboot to OTA Mode</DialogTitle>
<DialogTitle>
{t("rebootOta.title")}
</DialogTitle>
<DialogDescription>
Reboot the connected node after a delay into OTA (Over-the-Air) mode.
{t("rebootOta.description")}
</DialogDescription>
</DialogHeader>
@@ -85,20 +90,27 @@ export const RebootOTADialog = ({ open, onOpenChange }: RebootOTADialogProps) =>
className="dark:text-slate-900 appearance-none"
value={inputValue}
onChange={handleSetTime}
placeholder="Enter delay (sec)"
placeholder={t("rebootOta.enterDelay")}
/>
<Button onClick={() => handleRebootWithTimeout()} className="w-9/12">
<Button
onClick={() => handleRebootWithTimeout()}
data-testid="scheduleRebootBtn"
className="w-9/12"
>
<ClockIcon className="mr-2" size={18} />
{isScheduled ? 'Reboot has been scheduled' : 'Schedule Reboot'}
{isScheduled ? t("rebootOta.scheduled") : t("rebootOta.title")}
</Button>
</div>
<Button variant="destructive" onClick={() => handleInstantReboot()}>
<Button
variant="destructive"
name="rebootNow"
onClick={() => handleInstantReboot()}
>
<RefreshCwIcon className="mr-2" size={16} />
Reboot to OTA Mode Now
{t("button.rebootOtaNow")}
</Button>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,55 +1,49 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { beforeEach, describe, expect, it, Mock, vi } from "vitest";
import { RefreshKeysDialog } from "./RefreshKeysDialog";
import { render } from "@testing-library/react";
import { DeviceContext, useDeviceStore } from "@core/stores/deviceStore.ts";
import { RefreshKeysDialog } from "./RefreshKeysDialog.tsx";
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
vi.mock("./useRefreshKeysDialog.ts", () => ({
useRefreshKeysDialog: vi.fn(),
}));
vi.mock("@core/stores/messageStore");
vi.mock("./useRefreshKeysDialog");
describe("RefreshKeysDialog Component", () => {
let handleCloseDialogMock: Mock;
let handleNodeRemoveMock: Mock;
let onOpenChangeMock: Mock;
const mockUseMessageStore = vi.mocked(useMessageStore);
const mockUseRefreshKeysDialog = vi.mocked(useRefreshKeysDialog);
beforeEach(() => {
handleCloseDialogMock = vi.fn();
handleNodeRemoveMock = vi.fn();
onOpenChangeMock = vi.fn();
const getInitialState = () =>
useDeviceStore.getInitialState?.() ??
{ devices: new Map(), remoteDevices: new Map() };
(useRefreshKeysDialog as Mock).mockReturnValue({
handleCloseDialog: handleCloseDialogMock,
handleNodeRemove: handleNodeRemoveMock,
});
});
it("renders the dialog with correct content", () => {
render(<RefreshKeysDialog open={true} onOpenChange={onOpenChangeMock} />);
expect(screen.getByText("Keys Mismatch")).toBeInTheDocument();
expect(screen.getByText("Request New Keys")).toBeInTheDocument();
expect(screen.getByText("Dismiss")).toBeInTheDocument();
});
it("calls handleNodeRemove when 'Request New Keys' button is clicked", () => {
render(<RefreshKeysDialog open={true} onOpenChange={onOpenChangeMock} />);
fireEvent.click(screen.getByText("Request New Keys"));
expect(handleNodeRemoveMock).toHaveBeenCalled();
});
it("calls handleCloseDialog when 'Dismiss' button is clicked", () => {
render(<RefreshKeysDialog open={true} onOpenChange={onOpenChangeMock} />);
fireEvent.click(screen.getByText("Dismiss"));
expect(handleCloseDialogMock).toHaveBeenCalled();
});
it("calls onOpenChange when dialog close button is clicked", () => {
render(<RefreshKeysDialog open={true} onOpenChange={onOpenChangeMock} />);
fireEvent.click(screen.getByRole("button", { name: /close/i }));
expect(handleCloseDialogMock).toHaveBeenCalled();
});
it("does not render when open is false", () => {
render(<RefreshKeysDialog open={false} onOpenChange={onOpenChangeMock} />);
expect(screen.queryByText("Keys Mismatch")).not.toBeInTheDocument();
});
beforeEach(() => {
useDeviceStore.setState(getInitialState(), true);
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
test("does not render dialog if no error exists for active chat", () => {
const deviceId = 1;
const activeChatNum = 54321;
useDeviceStore.getState().addDevice(deviceId);
const currentDeviceState = useDeviceStore.getState().getDevice(deviceId);
if (!currentDeviceState) throw new Error("Device not found");
mockUseMessageStore.mockReturnValue({ activeChat: activeChatNum });
mockUseRefreshKeysDialog.mockReturnValue({
handleCloseDialog: vi.fn(),
handleNodeRemove: vi.fn(),
});
const { container } = render(
<DeviceContext.Provider value={currentDeviceState}>
<RefreshKeysDialog open onOpenChange={vi.fn()} />
</DeviceContext.Provider>,
);
expect(container.firstChild).toBeNull();
});

View File

@@ -8,54 +8,88 @@ import {
import { Button } from "@components/UI/Button.tsx";
import { LockKeyholeOpenIcon } from "lucide-react";
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useTranslation } from "react-i18next";
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
export interface RefreshKeysDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const RefreshKeysDialog = ({ open, onOpenChange }: RefreshKeysDialogProps) => {
export const RefreshKeysDialog = (
{ open, onOpenChange }: RefreshKeysDialogProps,
) => {
const { t } = useTranslation("dialog");
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: t("refreshKeys.title", {
identifier: nodeWithError?.user?.longName ?? "",
}),
description: `${t("refreshKeys.description.unableToSendDmPrefix")}${
nodeWithError?.user?.longName ?? ""
} (${nodeWithError?.user?.shortName ?? ""})${
t("refreshKeys.description.keyMismatchReasonSuffix")
}`,
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-8 flex flex-col gap-2">
<DialogContent
className="max-w-8 flex flex-col gap-2"
aria-describedby={undefined}
>
<DialogClose onClick={handleCloseDialog} />
<DialogHeader>
<DialogTitle>Keys Mismatch</DialogTitle>
<DialogTitle>{text.title}</DialogTitle>
</DialogHeader>
Your node is unable to send a direct message to this node. This is due to the remote node's current public key not matching the previously stored key for this node.
{text.description}
<ul className="mt-2">
<li className="flex place-items-center gap-2 items-start">
<div className="p-2 bg-slate-500 rounded-lg mt-1">
<LockKeyholeOpenIcon size={30} className="text-white justify-center" />
<LockKeyholeOpenIcon
size={30}
className="text-white justify-center"
/>
</div>
<div className="flex flex-col gap-2">
<div>
<p className="font-bold mb-0.5">Accept New Keys</p>
<p className="font-bold mb-0.5">
{t("refreshKeys.label.acceptNewKeys")}
</p>
<p>
This will remove the node from device and request new keys.
{t("refreshKeys.description.acceptNewKeys")}
</p>
</div>
<Button
variant="default"
name="requestNewKeys"
onClick={handleNodeRemove}
className=""
>
Request New Keys
{t("button.requestNewKeys")}
</Button>
<Button
variant="outline"
name="dismiss"
onClick={handleCloseDialog}
className=""
>
Dismiss
{t("button.dismiss")}
</Button>
</div>
</li>
</ul>
{/* </DialogDescription> */}
</DialogContent>
</Dialog >
</Dialog>
);
};

View File

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

View File

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

View File

@@ -11,6 +11,7 @@ import {
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { Label } from "@components/UI/Label.tsx";
import { useTranslation } from "react-i18next";
export interface RemoveNodeDialogProps {
open: boolean;
@@ -21,7 +22,8 @@ export const RemoveNodeDialog = ({
open,
onOpenChange,
}: RemoveNodeDialogProps) => {
const { connection, nodes, removeNode } = useDevice();
const { t } = useTranslation("dialog");
const { connection, getNode, removeNode } = useDevice();
const { nodeNumToBeRemoved } = useAppStore();
const onSubmit = () => {
@@ -35,19 +37,23 @@ export const RemoveNodeDialog = ({
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>Remove Node?</DialogTitle>
<DialogTitle>{t("removeNode.title")}</DialogTitle>
<DialogDescription>
Are you sure you want to remove this Node?
{t("removeNode.description")}
</DialogDescription>
</DialogHeader>
<div className="gap-4">
<form onSubmit={onSubmit}>
<Label>{nodes.get(nodeNumToBeRemoved)?.user?.longName}</Label>
<Label>{getNode(nodeNumToBeRemoved)?.user?.longName}</Label>
</form>
</div>
<DialogFooter>
<Button variant="destructive" onClick={() => onSubmit()}>
Remove
<Button
variant="destructive"
name="remove"
onClick={() => onSubmit()}
>
{t("button.remove")}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -10,6 +10,7 @@ import {
import { Input } from "@components/UI/Input.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { ClockIcon, PowerIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useState } from "react";
export interface ShutdownDialogProps {
@@ -21,6 +22,7 @@ export const ShutdownDialog = ({
open,
onOpenChange,
}: ShutdownDialogProps) => {
const { t } = useTranslation("dialog");
const { connection } = useDevice();
const [time, setTime] = useState<number>(5);
@@ -30,9 +32,11 @@ export const ShutdownDialog = ({
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>Schedule Shutdown</DialogTitle>
<DialogTitle>
{t("shutdown.title")}
</DialogTitle>
<DialogDescription>
Turn off the connected node after x minutes.
{t("shutdown.description")}
</DialogDescription>
</DialogHeader>
@@ -41,8 +45,7 @@ export const ShutdownDialog = ({
type="number"
value={time}
onChange={(e) => setTime(Number.parseInt(e.target.value))}
className="dark:text-slate-900"
suffix="Minutes"
suffix={t("unit.minute.plural")}
/>
<Button
className="w-24"
@@ -54,12 +57,13 @@ export const ShutdownDialog = ({
</Button>
<Button
className="w-24"
name="now"
onClick={() => {
connection?.shutdown(2).then(() => () => onOpenChange(false));
}}
>
<PowerIcon className="mr-2" size={16} />
Now
{t("button.now")}
</Button>
</div>
</DialogContent>

View File

@@ -1,4 +1,4 @@
import { useDevice } from "../../core/stores/deviceStore.ts";
import { useDevice } from "@core/stores/deviceStore.ts";
import {
Dialog,
DialogClose,
@@ -11,6 +11,7 @@ import type { Protobuf, Types } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { TraceRoute } from "../PageComponents/Messages/TraceRoute.tsx";
import { useTranslation } from "react-i18next";
export interface TracerouteResponseDialogProps {
traceroute: Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery> | undefined;
@@ -23,30 +24,43 @@ export const TracerouteResponseDialog = ({
open,
onOpenChange,
}: TracerouteResponseDialogProps) => {
const { nodes } = useDevice();
const { t } = useTranslation("dialog");
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 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 snrTowards = (traceroute?.data.snrTowards ?? []).map((snr) => snr / 4);
const snrBack = (traceroute?.data.snrBack ?? []).map((snr) => snr / 4);
const from = getNode(traceroute?.from ?? 0);
const fromLongName = from?.user?.longName ??
(from ? `!${numberToHexUnpadded(from?.num)}` : t("unknown.shortName"));
const fromShortName = from?.user?.shortName ??
(from
? `${numberToHexUnpadded(from?.num).substring(0, 4)}`
: t("unknown.shortName"));
const toUser = getNode(traceroute?.to ?? 0);
if (!toUser || !from) {
return null;
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>{`Traceroute: ${longName} (${shortName})`}</DialogTitle>
<DialogTitle>
{t("tracerouteResponse.title", {
identifier: `${fromLongName} (${fromShortName})`,
})}
</DialogTitle>
</DialogHeader>
<DialogDescription>
<TraceRoute
route={route}
routeBack={routeBack}
from={from}
to={to}
from={{ user: from.user }}
to={{ user: toUser.user }}
snrTowards={snrTowards}
snrBack={snrBack}
/>

View File

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

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