168 Commits
Author SHA1 Message Date
Dan DitomasoandGitHub 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 DitomasoandGitHub 6c676fa8da add git sub module (#678) 2025-06-23 22:26:01 -04:00
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
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 DitomasoandGitHub c6a564f7e4 fix: style on config page (#674) 2025-06-20 12:39:11 -04:00
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
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 DitomasoandGitHub 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
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
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
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
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 DitomasoandGitHub c91e5e6b7b fix: add rewrites to vercel.json (#662) 2025-06-17 11:09:43 -04:00
Jeremy GallantandGitHub 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 DitomasoandGitHub 118f848308 feat: add support for 3 languages (#661) 2025-06-16 16:22:45 -04:00
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 DitomasoandGitHub 474e610c3d fix: revert crowdin config (#659) 2025-06-16 15:55:29 -04:00
Dan DitomasoandGitHub 0b6ae0ce32 fix: update crowdin config (#658) 2025-06-16 15:14:59 -04:00
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 DitomasoandGitHub 6cc6986904 fix: remove service worker (#654) 2025-06-16 09:39:13 -04:00
Dan DitomasoandGitHub 26d5c0a08a feat: added i18n translator & developer guides (#646) 2025-06-12 20:15:43 -04:00
Dan DitomasoandGitHub 78e1d1f81a fix: updated mqtt description (#647) 2025-06-12 20:14:59 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
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 DitomasoandGitHub 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 DitomasoandGitHub 851da0707c Fix broken tests (#648) 2025-06-12 16:08:08 -04:00
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
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
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 DitomasoandGitHub 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 GrahamandGitHub 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 DitomasoandGitHub 24e9764fcb fix: updated namespace module (#632)
N33d this merged.
2025-05-27 11:13:51 -04:00
Dan DitomasoandGitHub 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
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-andGitHub 8413f6345c Fix #624 (#631) 2025-05-27 07:35:47 -04:00
philon-andGitHub 37a53b747c Fix NodeInfoDialog initiation (#626) 2025-05-27 07:14:57 -04:00
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 DitomasoandGitHub 9cb5cffdb1 Update README.md
Remove references to `master` replace with `main`
2025-05-21 11:58:03 -04:00
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-andGitHub 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-andGitHub 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-andGitHub 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 StewartandGitHub 3f933dd166 Remove case-duplicated sidebarButton.tsx (#617) 2025-05-11 20:01:43 -04:00
Dan DitomasoandGitHub 1180b9afb0 fix: moved all svgs into public / dist folder (#615) 2025-05-09 20:57:38 -04:00
philon-andGitHub 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 DitomasoandGitHub da0ada925f fix: removed overly complex scrolling logic in channel chat (#612) 2025-05-06 12:40:07 -04:00
Dan DitomasoandGitHub f9346931f8 update readme with deployment schedule (#611) 2025-05-06 10:10:24 -04:00
Dan DitomasoandGitHub 1d18abf6c1 fix: connect new node no longer crashes. (#610) 2025-05-06 10:08:42 -04:00
Dan DitomasoandGitHub 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 DitomasoandGitHub 1c453e2981 fix: couple of UI updates, fix: add pre release support to release workflow (#609) 2025-05-05 11:47:36 -04:00
philon-andGitHub 170abfcc2f fix: Adjust table odd/even colors (#606) 2025-05-05 11:23:37 -04:00
philon-andGitHub 0539b15ddc Fix #607 (#608) 2025-05-04 20:08:16 -04:00
Dan DitomasoandGitHub 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
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 DzieniszewskiandGitHub b7bdb1a502 chore: Update deno.lock version and add Radix UI slider component (#601) 2025-05-04 15:29:34 -04:00
philon-andGitHub 3be528d03a Fix config sidebar button state (#602) 2025-05-04 15:28:47 -04:00
Dan DitomasoandGitHub 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 DitomasoandGitHub cfcc9f82d8 Merge pull request #596 from philon-/style-fixes
Minor style fixes
2025-05-03 13:28:22 -04:00
Dan DitomasoandGitHub 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 DitomasoandGitHub 79910dfec7 fix: removed console logs in code (#592) 2025-05-02 11:48:18 -04:00
AustinandGitHub b40540b118 Fix upload build.tar (#591) 2025-05-02 10:57:17 -04:00
Dan DitomasoandGitHub 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 DitomasoandGitHub 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 DitomasoandGitHub ce71f22316 Merge pull request #580 from philon-/feature/map-filtering
Add filters to node map
2025-04-24 08:52:55 -04:00
Dan DitomasoandGitHub 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 DitomasoandGitHub c050998f3d Merge pull request #582 from meshtastic/danditomaso-patch-1
Update ci.yml
2025-04-23 15:48:37 -04:00
Dan DitomasoandGitHub 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-andGitHub 91d8776637 Merge branch 'meshtastic:master' into feature/map-filtering 2025-04-22 20:28:05 +02:00
Dan DitomasoandGitHub 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 DitomasoandGitHub 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 DitomasoandGitHub 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 DitomasoandGitHub 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 DitomasoandGitHub 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 DitomasoandGitHub 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 ThornsberryandGitHub ff43763721 Merge pull request #568 from Hunter275/clock-settings 2025-04-10 13:30:52 -04:00
Hunter ThornsberryandGitHub 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 DitomasoandGitHub 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 DitomasoandGitHub 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 DitomasoandGitHub 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 DitomasoandGitHub 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 DitomasoandGitHub 494a35a0c3 Merge pull request #497 from Hunter275/unread-counts
Unread Counts
2025-04-05 08:40:54 -04:00
Dan DitomasoandGitHub 818bbb4a30 fix broken test 2025-04-05 08:39:27 -04:00
Dan DitomasoandGitHub 4755c0eeb9 refactor to integrate messageStore and unreadCounts 2025-04-04 22:22:35 -04:00
Dan DitomasoandGitHub c8c89fdc95 Merge branch 'master' into unread-counts 2025-04-04 08:58:13 -04:00
Dan DitomasoandGitHub 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 DitomasoandGitHub 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 DitomasoandGitHub 5f424e2e0b Merge branch 'master' into add-message-persistance 2025-04-02 18:02:57 -04:00
James ThomasandDan Ditomaso d807cd2de7 Using existing Link component and standardizing colors 2025-04-02 18:00:44 -04:00
James ThomasandDan Ditomaso 0b4e3a8da9 Cleanup and add link to official docs 2025-04-02 18:00:44 -04:00
James ThomasandDan Ditomaso 31be5e9a25 Adding connection failure warning 2025-04-02 18:00:44 -04:00
vidplace7andDan Ditomaso 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
Hunter275andDan Ditomaso 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 ThomasandDan Ditomaso 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 ThomasandDan Ditomaso b30fbf90b9 Prevent tooltip from appearing by default 2025-04-02 17:58:30 -04:00
James ThomasandDan Ditomaso 8fb95e1b06 Lint 2025-04-02 17:58:30 -04:00
James ThomasandDan Ditomaso f5e1a0569f Lint 2025-04-02 17:58:30 -04:00
James ThomasandDan Ditomaso 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 DitomasoandGitHub 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 DitomasoandGitHub 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 ThomasandGitHub 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 ThomasandGitHub 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
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 DitomasoandGitHub 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 ThornsberryandGitHub 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 ThornsberryandGitHub 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 42508 additions and 7279 deletions
+12
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
+12 -10
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 ## Description
<!-- <!--
Provide a clear and concise description of what this PR does. Explain the problem it solves or the feature it adds. Provide a clear and concise description of what this PR does. Explain the problem it solves or the feature it adds.
--> -->
## Related Issues ## Related Issues
<!-- <!--
Link any related issues here using the GitHub syntax: "Fixes #123" or "Relates to #456". 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. If there are no related issues, you can remove this section.
--> -->
## Changes Made ## Changes Made
<!-- <!--
List the key changes you've made. Focus on the most important aspects that reviewers should understand. List the key changes you've made. Focus on the most important aspects that reviewers should understand.
--> -->
-
- -
- -
- -
## Testing Done ## Testing Done
<!-- <!--
Describe how you tested these changes. Describe how you tested these changes (added new tests, etc).
--> -->
## Screenshots (if applicable) ## Screenshots (if applicable)
<!-- <!--
If your changes affect the UI, include screenshots or screencasts showing the before and after. If your changes affect the UI, include screenshots or screencasts showing the before and after.
--> -->
## Checklist ## Checklist
<!-- <!--
Check all that apply. If an item doesn't apply to your PR, you can leave it unchecked or remove it. 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 - [ ] Code follows project style guidelines
- [ ] Documentation has been updated or added - [ ] Documentation has been updated or added
- [ ] Tests have been added or updated - [ ] Tests have been added or updated
- [ ] All CI checks pass - [ ] All i18n translation labels have been added (read
- [ ] Dependent changes have been merged CONTRIBUTING_I18N_DEVELOPER_GUIDE.md for more details)
## Additional Notes
<!--
Add any other context about the PR here.
-->
+21 -2
View File
@@ -1,9 +1,9 @@
name: CI name: Push to Main CI
on: on:
push: push:
branches: branches:
- master - main
permissions: permissions:
contents: write contents: write
@@ -24,6 +24,25 @@ jobs:
- name: Install Dependencies - name: Install Dependencies
run: deno install 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 - name: Run tests
run: deno task test run: deno task test
+35
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 }}
@@ -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 }}
@@ -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 }}
+37 -2
View File
@@ -1,6 +1,7 @@
name: Pull Request name: Pull Request CI
on: pull_request on:
pull_request:
jobs: jobs:
build: build:
@@ -14,9 +15,43 @@ jobs:
with: with:
deno-version: v2.x 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 - name: Install Dependencies
run: deno install 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 - name: Run tests
run: deno task test run: deno task test
+8 -2
View File
@@ -1,8 +1,8 @@
name: 'Release' name: Release
on: on:
release: release:
types: [released] types: [released, prereleased]
permissions: permissions:
contents: write contents: write
@@ -38,6 +38,12 @@ jobs:
name: build name: build
path: dist/build.tar 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 - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
@@ -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
+1
View File
@@ -5,3 +5,4 @@ stats.html
.vite .vite
dev-dist dev-dist
__screenshots__* __screenshots__*
*.diff
+3
View File
@@ -0,0 +1,3 @@
[submodule "src/core/connection"]
path = src/core/connection
url = https://github.com/meshtastic/js.git
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
"recommendations": ["bradlc.vscode-tailwindcss", "biomejs.biome"] "recommendations": ["bradlc.vscode-tailwindcss", "denoland.vscode-deno"]
} }
+112
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>;
```
+31
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!
+16 -7
View File
@@ -2,7 +2,7 @@
<!--Project specific badges here--> <!--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) [![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/) [![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) [![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 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 Our release process follows these guidelines:
features and fixes. These builds are automatically generated from the latest
main branch every night and are available for testing and early adoption. - **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 ```bash
# With Docker # With Docker
@@ -73,7 +82,7 @@ podman run -d -p 8080:8080 --restart always --name Meshtastic-Web ghcr.io/meshta
> new features > new features
> - No guarantee of backward compatibility between nightly builds > - No guarantee of backward compatibility between nightly builds
### Version Information #### Version Information
Each nightly build is tagged with: Each nightly build is tagged with:
@@ -161,6 +170,6 @@ requests:
Meshtastic nodes. Meshtastic nodes.
Please review our 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 before submitting a pull request. We appreciate your help in making the project
better! better!
+10
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%"
+29 -3
View File
@@ -4,8 +4,10 @@
"@pages/": "./src/pages/", "@pages/": "./src/pages/",
"@components/": "./src/components/", "@components/": "./src/components/",
"@core/": "./src/core/", "@core/": "./src/core/",
"@layouts/": "./src/layouts/" "@layouts/": "./src/layouts/",
"@std/path": "jsr:@std/path@^1.1.0"
}, },
"include": ["src", "./vite-env.d.ts"],
"compilerOptions": { "compilerOptions": {
"lib": [ "lib": [
"DOM", "DOM",
@@ -23,11 +25,35 @@
"types": [ "types": [
"vite/client", "vite/client",
"node", "node",
"@types/web-bluetooth", "npm:@types/w3c-web-serial",
"@types/w3c-web-serial" "npm:@types/web-bluetooth"
], ],
"strictPropertyInitialization": false "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": [ "unstable": [
"sloppy-imports" "sloppy-imports"
] ]
Generated
+1741 -1232
View File
File diff suppressed because it is too large Load Diff
+56 -44
View File
@@ -11,8 +11,9 @@
"lint:fix": "deno lint --fix src/", "lint:fix": "deno lint --fix src/",
"format": "deno fmt src/", "format": "deno fmt src/",
"dev": "deno task dev:ui", "dev": "deno task dev:ui",
"dev:ui": "deno run -A npm:vite dev", "dev:ui": "VITE_APP_VERSION=development deno run -A npm:vite dev",
"test": "deno run -A npm:vitest", "test": "deno run -A npm:vitest",
"check": "deno check",
"preview": "deno run -A npm:vite preview", "preview": "deno run -A npm:vite preview",
"package": "gzipper c -i html,js,css,png,ico,svg,webmanifest,txt dist dist/output && tar -cvf dist/build.tar -C ./dist/output/ ." "package": "gzipper c -i html,js,css,png,ico,svg,webmanifest,txt dist dist/output && tar -cvf dist/build.tar -C ./dist/output/ ."
}, },
@@ -34,76 +35,87 @@
}, },
"homepage": "https://meshtastic.org", "homepage": "https://meshtastic.org",
"dependencies": { "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/js": "npm:@jsr/meshtastic__js@2.6.0-0",
"@meshtastic/transport-http": "npm:@jsr/meshtastic__transport-http", "@meshtastic/transport-http": "npm:@jsr/meshtastic__transport-http",
"@meshtastic/transport-web-bluetooth": "npm:@jsr/meshtastic__transport-web-bluetooth", "@meshtastic/transport-web-bluetooth": "npm:@jsr/meshtastic__transport-web-bluetooth",
"@meshtastic/transport-web-serial": "npm:@jsr/meshtastic__transport-web-serial", "@meshtastic/transport-web-serial": "npm:@jsr/meshtastic__transport-web-serial",
"@bufbuild/protobuf": "^2.2.5", "@noble/curves": "^1.9.0",
"@noble/curves": "^1.8.1", "@radix-ui/react-accordion": "^1.2.8",
"@radix-ui/react-accordion": "^1.2.3", "@radix-ui/react-checkbox": "^1.2.3",
"@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-dialog": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dropdown-menu": "^2.1.12",
"@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-label": "^2.1.4",
"@radix-ui/react-label": "^2.1.2", "@radix-ui/react-menubar": "^1.1.12",
"@radix-ui/react-menubar": "^1.1.6", "@radix-ui/react-popover": "^1.1.11",
"@radix-ui/react-popover": "^1.1.6", "@radix-ui/react-scroll-area": "^1.2.6",
"@radix-ui/react-scroll-area": "^1.2.3", "@radix-ui/react-select": "^2.2.2",
"@radix-ui/react-select": "^2.1.6", "@radix-ui/react-separator": "^1.1.4",
"@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-slider": "^1.3.2",
"@radix-ui/react-switch": "^1.1.3", "@radix-ui/react-switch": "^1.2.2",
"@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-tabs": "^1.1.9",
"@radix-ui/react-toast": "^1.2.6", "@radix-ui/react-toast": "^1.2.11",
"@radix-ui/react-tooltip": "^1.1.8", "@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", "@turf/turf": "^7.2.0",
"@types/web-bluetooth": "^0.0.21",
"base64-js": "^1.5.1", "base64-js": "^1.5.1",
"class-validator": "^0.14.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"crypto-random-string": "^5.0.0", "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", "immer": "^10.1.1",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"lucide-react": "^0.486.0", "lucide-react": "^0.507.0",
"maplibre-gl": "5.3.0", "maplibre-gl": "5.4.0",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-error-boundary": "^5.0.0", "react-error-boundary": "^6.0.0",
"react-hook-form": "^7.55.0", "react-hook-form": "^7.56.2",
"react-map-gl": "8.0.2", "react-i18next": "^15.5.1",
"react-map-gl": "8.0.4",
"react-qrcode-logo": "^3.0.0", "react-qrcode-logo": "^3.0.0",
"rfc4648": "^1.5.4", "rfc4648": "^1.5.4",
"vite-plugin-node-polyfills": "^0.23.0", "zod": "^3.25.67",
"zod": "^3.24.2", "zustand": "5.0.5"
"zustand": "5.0.3"
}, },
"devDependencies": { "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/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0", "@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1", "@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/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/w3c-web-serial": "^1.0.8",
"@types/web-bluetooth": "^0.0.21", "@vitejs/plugin-react": "^4.4.1",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"gzipper": "^8.2.1", "gzipper": "^8.2.1",
"happy-dom": "^17.4.4", "happy-dom": "^17.4.6",
"postcss": "^8.5.3", "postcss": "^8.5.3",
"simple-git-hooks": "^2.12.1", "simple-git-hooks": "^2.13.0",
"tailwind-merge": "^3.1.0", "tailwind-merge": "^3.2.0",
"tailwindcss": "^4.1.0", "tailwindcss": "^4.1.5",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"tar": "^7.4.3", "tar": "^7.4.3",
"testing-library": "^0.0.2", "testing-library": "^0.0.2",
"typescript": "^5.8.2", "typescript": "^5.8.3",
"vite": "^6.2.4", "vite": "^6.3.4",
"vitest": "^3.1.1", "vite-plugin-pwa": "^1.0.0",
"vite-plugin-pwa": "^1.0.0" "vite-plugin-static-copy": "^3.0.0",
"vitest": "^3.1.2"
} }
} }
+16
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

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

-5
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).

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 9.9 KiB

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 9.9 KiB

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 164 KiB

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

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

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 120 KiB

+23 -15
View File
@@ -1,6 +1,4 @@
import { DeviceWrapper } from "@app/DeviceWrapper.tsx"; 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 { DialogManager } from "@components/Dialog/DialogManager.tsx";
import { NewDeviceDialog } from "@components/Dialog/NewDeviceDialog.tsx"; import { NewDeviceDialog } from "@components/Dialog/NewDeviceDialog.tsx";
import { KeyBackupReminder } from "@components/KeyBackupReminder.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 { useAppStore } from "@core/stores/appStore.ts";
import { useDeviceStore } from "@core/stores/deviceStore.ts"; import { useDeviceStore } from "@core/stores/deviceStore.ts";
import { Dashboard } from "@pages/Dashboard/index.tsx"; import { Dashboard } from "@pages/Dashboard/index.tsx";
import type { JSX } from "react";
import { ErrorBoundary } from "react-error-boundary"; import { ErrorBoundary } from "react-error-boundary";
import { ErrorPage } from "@components/UI/ErrorPage.tsx"; import { ErrorPage } from "@components/UI/ErrorPage.tsx";
import { MapProvider } from "react-map-gl/maplibre"; import { MapProvider } from "react-map-gl/maplibre";
import { CommandPalette } from "@components/CommandPalette/index.tsx"; 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 function App() {
export const App = (): JSX.Element => {
const { getDevice } = useDeviceStore(); const { getDevice } = useDeviceStore();
const { selectedDevice, setConnectDialogOpen, connectDialogOpen } = const { selectedDevice, setConnectDialogOpen, connectDialogOpen } =
useAppStore(); useAppStore();
const device = getDevice(selectedDevice); const device = getDevice(selectedDevice);
// Sets up light/dark mode based on user preferences or system settings
useTheme();
return ( return (
<ErrorBoundary FallbackComponent={ErrorPage}> <ErrorBoundary FallbackComponent={ErrorPage}>
<NewDeviceDialog <NewDeviceDialog
@@ -32,30 +35,35 @@ export const App = (): JSX.Element => {
}} }}
/> />
<Toaster /> <Toaster />
<TanStackRouterDevtools position="bottom-right" />
<DeviceWrapper device={device}> <DeviceWrapper device={device}>
<div className="flex h-screen flex-col overflow-hidden bg-background-primary text-text-primary"> <div
<div className="flex grow"> className="flex h-screen flex-col bg-background-primary text-text-primary"
<DeviceSelector /> style={{ scrollbarWidth: "thin" }}
<div className="flex grow flex-col"> >
{device ? ( <SidebarProvider>
<div className="flex h-screen w-full"> <div className="h-full flex flex-col">
{device
? (
<div className="h-full flex w-full">
<DialogManager /> <DialogManager />
<KeyBackupReminder /> <KeyBackupReminder />
<CommandPalette /> <CommandPalette />
<MapProvider> <MapProvider>
<PageRouter /> <Outlet />
</MapProvider> </MapProvider>
</div> </div>
) : ( )
: (
<> <>
<Dashboard /> <Dashboard />
<Footer /> <Footer />
</> </>
)} )}
</div> </div>
</div> </SidebarProvider>
</div> </div>
</DeviceWrapper> </DeviceWrapper>
</ErrorBoundary> </ErrorBoundary>
); );
}; }
-27
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>
);
};
+9 -8
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 }: { Button: ({ children, name, disabled, onClick }: {
children: React.ReactNode, children: React.ReactNode;
variant: string, variant: string;
name: string, name: string;
disabled?: boolean, disabled?: boolean;
onClick: () => void onClick: () => void;
}) => }) => (
<button <button
type="button" type="button"
name={name} name={name}
@@ -17,4 +17,5 @@ vi.mock('@components/UI/Button.tsx', () => ({
> >
{children} {children}
</button> </button>
),
})); }));
+17 -4
View File
@@ -1,6 +1,19 @@
import { vi } from 'vitest' import { vi } from "vitest";
vi.mock('@components/UI/Checkbox.tsx', () => ({ vi.mock("@components/UI/Checkbox.tsx", () => ({
Checkbox: ({ id, checked, onChange }: { id: string, checked: boolean, onChange: () => void }) => Checkbox: (
<input data-testid="checkbox" type="checkbox" id={id} checked={checked} onChange={onChange} /> { id, checked, onChange }: {
id: string;
checked: boolean;
onChange: () => void;
},
) => (
<input
data-testid="checkbox"
type="checkbox"
id={id}
checked={checked}
onChange={onChange}
/>
),
})); }));
+20 -18
View File
@@ -1,43 +1,45 @@
import React from 'react'; import React from "react";
export const Dialog = ({ children, open }: { export const Dialog = ({ children, open }: {
children: React.ReactNode, children: React.ReactNode;
open: boolean, open: boolean;
onOpenChange?: (open: boolean) => void onOpenChange?: (open: boolean) => void;
}) => open ? <div data-testid="dialog">{children}</div> : null; }) => open ? <div data-testid="dialog">{children}</div> : null;
export const DialogContent = ({ export const DialogContent = ({
children, children,
className className,
}: { }: {
children: React.ReactNode, children: React.ReactNode;
className?: string className?: string;
}) => <div data-testid="dialog-content" className={className}>{children}</div>; }) => <div data-testid="dialog-content" className={className}>{children}</div>;
export const DialogHeader = ({ export const DialogHeader = ({
children children,
}: { }: {
children: React.ReactNode children: React.ReactNode;
}) => <div data-testid="dialog-header">{children}</div>; }) => <div data-testid="dialog-header">{children}</div>;
export const DialogTitle = ({ export const DialogTitle = ({
children children,
}: { }: {
children: React.ReactNode children: React.ReactNode;
}) => <div data-testid="dialog-title">{children}</div>; }) => <div data-testid="dialog-title">{children}</div>;
export const DialogDescription = ({ export const DialogDescription = ({
children, children,
className className,
}: { }: {
children: React.ReactNode, children: React.ReactNode;
className?: string className?: string;
}) => <div data-testid="dialog-description" className={className}>{children}</div>; }) => (
<div data-testid="dialog-description" className={className}>{children}</div>
);
export const DialogFooter = ({ export const DialogFooter = ({
children, children,
className className,
}: { }: {
children: React.ReactNode, children: React.ReactNode;
className?: string className?: string;
}) => <div data-testid="dialog-footer" className={className}>{children}</div>; }) => <div data-testid="dialog-footer" className={className}>{children}</div>;
+13 -4
View File
@@ -1,6 +1,15 @@
import { vi } from 'vitest' import { vi } from "vitest";
vi.mock('@components/UI/Label.tsx', () => ({ vi.mock("@components/UI/Label.tsx", () => ({
Label: ({ children, htmlFor, className }: { children: React.ReactNode, htmlFor: string, className?: string }) => Label: (
<label data-testid="label" htmlFor={htmlFor} className={className}>{children}</label> { children, htmlFor, className }: {
children: React.ReactNode;
htmlFor: string;
className?: string;
},
) => (
<label data-testid="label" htmlFor={htmlFor} className={className}>
{children}
</label>
),
})); }));
+8 -4
View File
@@ -1,7 +1,11 @@
import { vi } from "vitest"; import { vi } from "vitest";
vi.mock('@components/UI/Typography/Link.tsx', () => ({ vi.mock("@components/UI/Typography/Link.tsx", () => ({
Link: ({ children, href, className }: { children: React.ReactNode, href: string, className?: string }) => Link: (
<a data-testid="link" href={href} className={className}>{children}</a> { children, href, className }: {
children: React.ReactNode;
href: string;
className?: string;
},
) => <a data-testid="link" href={href} className={className}>{children}</a>,
})); }));
+102
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;
+64 -54
View File
@@ -17,8 +17,10 @@ import {
FactoryIcon, FactoryIcon,
LayersIcon, LayersIcon,
LinkIcon, LinkIcon,
type LucideIcon,
MapIcon, MapIcon,
MessageSquareIcon, MessageSquareIcon,
Pin,
PlusIcon, PlusIcon,
PowerIcon, PowerIcon,
QrCodeIcon, QrCodeIcon,
@@ -27,15 +29,16 @@ import {
SmartphoneIcon, SmartphoneIcon,
TrashIcon, TrashIcon,
UsersIcon, UsersIcon,
Pin,
type LucideIcon,
} from "lucide-react"; } from "lucide-react";
import { useEffect } from "react"; import { useEffect } from "react";
import { Avatar } from "@components/UI/Avatar.tsx"; import { Avatar } from "@components/UI/Avatar.tsx";
import { cn } from "@core/utils/cn.ts"; import { cn } from "@core/utils/cn.ts";
import { useTranslation } from "react-i18next";
import { usePinnedItems } from "@core/hooks/usePinnedItems.ts"; import { usePinnedItems } from "@core/hooks/usePinnedItems.ts";
import { useNavigate } from "@tanstack/react-router";
export interface Group { export interface Group {
id: string;
label: string; label: string;
icon: LucideIcon; icon: LucideIcon;
commands: Command[]; commands: Command[];
@@ -57,72 +60,76 @@ export const CommandPalette = () => {
const { const {
commandPaletteOpen, commandPaletteOpen,
setCommandPaletteOpen, setCommandPaletteOpen,
setConnectDialogOpen,
setSelectedDevice, setSelectedDevice,
} = useAppStore(); } = useAppStore();
const { getDevices } = useDeviceStore(); const { getDevices } = useDeviceStore();
const { setDialogOpen, setActivePage, connection } = useDevice(); const { setDialogOpen, getNode, connection } = useDevice();
const { pinnedItems, togglePinnedItem } = usePinnedItems({ storageName: 'pinnedCommandMenuGroups' }); const { pinnedItems, togglePinnedItem } = usePinnedItems({
storageName: "pinnedCommandMenuGroups",
});
const { t } = useTranslation("commandPalette");
const navigate = useNavigate({ from: "/" });
const groups: Group[] = [ const groups: Group[] = [
{ {
label: "Goto", id: "gotoGroup",
label: t("goto.label"),
icon: LinkIcon, icon: LinkIcon,
commands: [ commands: [
{ {
label: "Messages", label: t("goto.command.messages"),
icon: MessageSquareIcon, icon: MessageSquareIcon,
action() { action() {
setActivePage("messages"); navigate({ to: "/messages" });
}, },
}, },
{ {
label: "Map", label: t("goto.command.map"),
icon: MapIcon, icon: MapIcon,
action() { action() {
setActivePage("map"); navigate({ to: "/map" });
}, },
}, },
{ {
label: "Config", label: t("goto.command.config"),
icon: SettingsIcon, icon: SettingsIcon,
action() { action() {
setActivePage("config"); navigate({ to: "/config" });
}, },
tags: ["settings"], tags: ["settings"],
}, },
{ {
label: "Channels", label: t("goto.command.channels"),
icon: LayersIcon, icon: LayersIcon,
action() { action() {
setActivePage("channels"); navigate({ to: "/channels" });
}, },
}, },
{ {
label: "Nodes", label: t("goto.command.nodes"),
icon: UsersIcon, icon: UsersIcon,
action() { action() {
setActivePage("nodes"); navigate({ to: "/nodes" });
}, },
}, },
], ],
}, },
{ {
label: "Manage", id: "manageGroup",
label: t("manage.label"),
icon: SmartphoneIcon, icon: SmartphoneIcon,
commands: [ commands: [
{ {
label: "Switch Node", label: t("manage.command.switchNode"),
icon: ArrowLeftRightIcon, icon: ArrowLeftRightIcon,
subItems: getDevices().map((device) => ({ subItems: getDevices().map((device) => ({
label: label: getNode(device.hardware.myNodeNum)?.user?.longName ??
device.nodes.get(device.hardware.myNodeNum)?.user?.longName ?? t("unknown.shortName"),
device.hardware.myNodeNum.toString(),
icon: ( icon: (
<Avatar <Avatar
text={ text={getNode(device.hardware.myNodeNum)?.user?.shortName ??
device.nodes.get(device.hardware.myNodeNum)?.user?.shortName ?? t("unknown.shortName")}
device.hardware.myNodeNum.toString()
}
/> />
), ),
action() { action() {
@@ -131,31 +138,32 @@ export const CommandPalette = () => {
})), })),
}, },
{ {
label: "Connect New Node", label: t("manage.command.connectNewNode"),
icon: PlusIcon, icon: PlusIcon,
action() { action() {
setSelectedDevice(0); setConnectDialogOpen(true);
}, },
}, },
], ],
}, },
{ {
label: "Contextual", id: "contextualGroup",
label: t("contextual.label"),
icon: BoxSelectIcon, icon: BoxSelectIcon,
commands: [ commands: [
{ {
label: "QR Code", label: t("contextual.command.qrCode"),
icon: QrCodeIcon, icon: QrCodeIcon,
subItems: [ subItems: [
{ {
label: "Generator", label: t("contextual.command.qrGenerator"),
icon: <QrCodeIcon size={16} />, icon: <QrCodeIcon size={16} />,
action() { action() {
setDialogOpen("QR", true); setDialogOpen("QR", true);
}, },
}, },
{ {
label: "Import", label: t("contextual.command.qrImport"),
icon: <QrCodeIcon size={16} />, icon: <QrCodeIcon size={16} />,
action() { action() {
setDialogOpen("import", true); setDialogOpen("import", true);
@@ -164,42 +172,42 @@ export const CommandPalette = () => {
], ],
}, },
{ {
label: "Schedule Shutdown", label: t("contextual.command.scheduleShutdown"),
icon: PowerIcon, icon: PowerIcon,
action() { action() {
setDialogOpen("shutdown", true); setDialogOpen("shutdown", true);
}, },
}, },
{ {
label: "Schedule Reboot", label: t("contextual.command.scheduleReboot"),
icon: RefreshCwIcon, icon: RefreshCwIcon,
action() { action() {
setDialogOpen("reboot", true); setDialogOpen("reboot", true);
}, },
}, },
{ {
label: "Reboot To OTA Mode", label: t("contextual.command.rebootToOtaMode"),
icon: RefreshCwIcon, icon: RefreshCwIcon,
action() { action() {
setDialogOpen("rebootOTA", true); setDialogOpen("rebootOTA", true);
}, },
}, },
{ {
label: "Reset Nodes", label: t("contextual.command.resetNodeDb"),
icon: TrashIcon, icon: TrashIcon,
action() { action() {
connection?.resetNodes(); connection?.resetNodes();
}, },
}, },
{ {
label: "Factory Reset Device", label: t("contextual.command.factoryResetDevice"),
icon: FactoryIcon, icon: FactoryIcon,
action() { action() {
connection?.factoryResetDevice(); connection?.factoryResetDevice();
}, },
}, },
{ {
label: "Factory Reset Config", label: t("contextual.command.factoryResetConfig"),
icon: FactoryIcon, icon: FactoryIcon,
action() { action() {
connection?.factoryResetConfig(); connection?.factoryResetConfig();
@@ -208,21 +216,22 @@ export const CommandPalette = () => {
], ],
}, },
{ {
label: "Debug", id: "debugGroup",
label: t("debug.label"),
icon: BugIcon, icon: BugIcon,
commands: [ commands: [
{ {
label: "Reconfigure", label: t("debug.command.reconfigure"),
icon: RefreshCwIcon, icon: RefreshCwIcon,
action() { action() {
void connection?.configure(); void connection?.configure();
}, },
}, },
{ {
label: "[WIP] Clear Messages", label: t("debug.command.clearAllStoredMessages"),
icon: EraserIcon, icon: EraserIcon,
action() { action() {
alert("This feature is not implemented"); setDialogOpen("deleteMessages", true);
}, },
}, },
], ],
@@ -230,8 +239,8 @@ export const CommandPalette = () => {
]; ];
const sortedGroups = [...groups].sort((a, b) => { const sortedGroups = [...groups].sort((a, b) => {
const aPinned = pinnedItems.includes(a.label) ? 1 : 0; const aPinned = pinnedItems.includes(a.id) ? 1 : 0;
const bPinned = pinnedItems.includes(b.label) ? 1 : 0; const bPinned = pinnedItems.includes(b.id) ? 1 : 0;
return bPinned - aPinned; return bPinned - aPinned;
}); });
@@ -248,10 +257,13 @@ export const CommandPalette = () => {
}, [setCommandPaletteOpen]); }, [setCommandPaletteOpen]);
return ( return (
<CommandDialog open={commandPaletteOpen} onOpenChange={setCommandPaletteOpen}> <CommandDialog
<CommandInput placeholder="Type a command or search..." /> open={commandPaletteOpen}
onOpenChange={setCommandPaletteOpen}
>
<CommandInput placeholder={t("search.commandPalette")} />
<CommandList> <CommandList>
<CommandEmpty>No results found.</CommandEmpty> <CommandEmpty>{t("emptyState")}</CommandEmpty>
{sortedGroups.map((group) => ( {sortedGroups.map((group) => (
<CommandGroup <CommandGroup
key={group.label} key={group.label}
@@ -260,15 +272,13 @@ export const CommandPalette = () => {
<span>{group.label}</span> <span>{group.label}</span>
<button <button
type="button" type="button"
onClick={() => togglePinnedItem(group.label)} onClick={() => togglePinnedItem(group.id)}
className={cn( 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={ aria-description={pinnedItems.includes(group.label)
pinnedItems.includes(group.label) ? t("unpinGroup.label")
? "Unpin command group" : t("pinGroup.label")}
: "Pin command group"
}
> >
<span <span
data-label data-label
@@ -278,9 +288,9 @@ export const CommandPalette = () => {
size={16} size={16}
className={cn( className={cn(
"transition-opacity", "transition-opacity",
pinnedItems.includes(group.label) pinnedItems.includes(group.id)
? "opacity-100 text-red-500" ? "opacity-100 text-red-500"
: "opacity-40 hover:opacity-70" : "opacity-40 hover:opacity-70",
)} )}
/> />
</button> </button>
+239
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>
</>
);
};
-76
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>
);
};
-25
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>
);
@@ -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);
});
});
@@ -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>
);
};
+90 -23
View File
@@ -10,10 +10,13 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@components/UI/Dialog.tsx"; } 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 { Protobuf } from "@meshtastic/core";
import { useForm } from "react-hook-form"; 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 { export interface User {
longName: string; longName: string;
@@ -29,52 +32,116 @@ export const DeviceNameDialog = ({
open, open,
onOpenChange, onOpenChange,
}: DeviceNameDialogProps) => { }: 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>({ const deviceNameSchema = z.object({
values: { longName: z
longName: myNode?.user?.longName ?? "Unknown", .string()
shortName: myNode?.user?.shortName ?? "Unknown", .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) => { const onSubmit = handleSubmit((data) => {
connection?.setOwner( connection?.setOwner(
create(Protobuf.Mesh.UserSchema, { create(Protobuf.Mesh.UserSchema, {
...myNode?.user,
...data, ...data,
}), }),
); );
onOpenChange(false); onOpenChange(false);
}); });
const handleReset = () => {
reset(defaultValues);
};
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent> <DialogContent>
<DialogClose /> <DialogClose />
<DialogHeader> <DialogHeader>
<DialogTitle>Change Device Name</DialogTitle> <DialogTitle>{t("deviceName.title")}</DialogTitle>
<DialogDescription> <DialogDescription>
The Device will restart once the config is saved. {t("deviceName.description")}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="gap-4"> <form onSubmit={onSubmit} className="flex flex-col gap-4">
<form onSubmit={onSubmit}> <div>
<Label>Long Name</Label> <Label htmlFor="longName">
<Input className="dark:text-slte-900" {...register("longName")} /> {t("deviceName.longName")}
<Label>Short Name</Label> </Label>
<Input <GenericInput
className="dark:text-slte-900" control={control}
maxLength={4} field={{
{...register("shortName")} 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> </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> <DialogFooter>
<Button onClick={() => onSubmit()}>Save</Button> <Button
type="button"
variant="destructive"
name="reset"
onClick={handleReset}
>
{t("button.reset")}
</Button>
<Button
type="submit"
name="save"
>
{t("button.save")}
</Button>
</DialogFooter> </DialogFooter>
</form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
+7
View File
@@ -10,6 +10,7 @@ import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDeta
import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx"; import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx";
import { RefreshKeysDialog } from "@components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx"; import { RefreshKeysDialog } from "@components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx";
import { RebootOTADialog } from "@components/Dialog/RebootOTADialog.tsx"; import { RebootOTADialog } from "@components/Dialog/RebootOTADialog.tsx";
import { DeleteMessagesDialog } from "@components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx";
export const DialogManager = () => { export const DialogManager = () => {
const { channels, config, dialog, setDialogOpen } = useDevice(); const { channels, config, dialog, setDialogOpen } = useDevice();
@@ -84,6 +85,12 @@ export const DialogManager = () => {
setDialogOpen("rebootOTA", open); setDialogOpen("rebootOTA", open);
}} }}
/> />
<DeleteMessagesDialog
open={dialog.deleteMessages}
onOpenChange={(open) => {
setDialogOpen("deleteMessages", open);
}}
/>
</> </>
); );
}; };
+15 -12
View File
@@ -17,6 +17,7 @@ import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core"; import { Protobuf } from "@meshtastic/core";
import { toByteArray } from "base64-js"; import { toByteArray } from "base64-js";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
export interface ImportDialogProps { export interface ImportDialogProps {
open: boolean; open: boolean;
@@ -28,6 +29,7 @@ export const ImportDialog = ({
open, open,
onOpenChange, onOpenChange,
}: ImportDialogProps) => { }: ImportDialogProps) => {
const { t } = useTranslation("dialog");
const [importDialogInput, setImportDialogInput] = useState<string>(""); const [importDialogInput, setImportDialogInput] = useState<string>("");
const [channelSet, setChannelSet] = useState<Protobuf.AppOnly.ChannelSet>(); const [channelSet, setChannelSet] = useState<Protobuf.AppOnly.ChannelSet>();
const [validUrl, setValidUrl] = useState<boolean>(false); const [validUrl, setValidUrl] = useState<boolean>(false);
@@ -44,7 +46,7 @@ export const ImportDialog = ({
channelsUrl.pathname !== "/e/") || channelsUrl.pathname !== "/e/") ||
!channelsUrl.hash !channelsUrl.hash
) { ) {
throw "Invalid Meshtastic URL"; throw t("import.error.invalidUrl");
} }
const encodedChannelConfig = channelsUrl.hash.substring(1); const encodedChannelConfig = channelsUrl.hash.substring(1);
@@ -70,7 +72,8 @@ export const ImportDialog = ({
}, [importDialogInput]); }, [importDialogInput]);
const apply = () => { const apply = () => {
channelSet?.settings.map((ch: unknown, index: number) => { channelSet?.settings.map(
(ch: Protobuf.Channel.ChannelSettings, index: number) => {
connection?.setChannel( connection?.setChannel(
create(Protobuf.Channel.ChannelSchema, { create(Protobuf.Channel.ChannelSchema, {
index, index,
@@ -80,7 +83,8 @@ export const ImportDialog = ({
settings: ch, settings: ch,
}), }),
); );
}); },
);
if (channelSet?.loraConfig) { if (channelSet?.loraConfig) {
connection?.setConfig( connection?.setConfig(
@@ -99,17 +103,16 @@ export const ImportDialog = ({
<DialogContent> <DialogContent>
<DialogClose /> <DialogClose />
<DialogHeader> <DialogHeader>
<DialogTitle>Import Channel Set</DialogTitle> <DialogTitle>{t("import.title")}</DialogTitle>
<DialogDescription> <DialogDescription>
The current LoRa configuration will be overridden. {t("import.description")}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<Label>Channel Set/QR Code URL</Label> <Label>{t("import.channelSetUrl")}</Label>
<Input <Input
value={importDialogInput} value={importDialogInput}
suffix={validUrl ? "✅" : "❌"} suffix={validUrl ? "✅" : "❌"}
className="dark:text-slate-900"
onChange={(e) => { onChange={(e) => {
setImportDialogInput(e.target.value); setImportDialogInput(e.target.value);
}} }}
@@ -118,7 +121,7 @@ export const ImportDialog = ({
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex w-full gap-2"> <div className="flex w-full gap-2">
<div className="w-36"> <div className="w-36">
<Label>Use Preset?</Label> <Label>{t("import.usePreset")}</Label>
<Switch <Switch
disabled disabled
checked={channelSet?.loraConfig?.usePreset ?? true} checked={channelSet?.loraConfig?.usePreset ?? true}
@@ -145,7 +148,7 @@ export const ImportDialog = ({
} }
<span className="text-md block font-medium text-text-primary"> <span className="text-md block font-medium text-text-primary">
Channels: {t("import.channels")}
</span> </span>
<div className="flex w-40 flex-col gap-1"> <div className="flex w-40 flex-col gap-1">
{channelSet?.settings.map((channel) => ( {channelSet?.settings.map((channel) => (
@@ -153,7 +156,7 @@ export const ImportDialog = ({
<Label> <Label>
{channel.name.length {channel.name.length
? channel.name ? channel.name
: `Channel: ${channel.id}`} : `${t("import.channelPrefix")}${channel.id}`}
</Label> </Label>
<Checkbox key={channel.id} /> <Checkbox key={channel.id} />
</div> </div>
@@ -163,8 +166,8 @@ export const ImportDialog = ({
)} )}
</div> </div>
<DialogFooter> <DialogFooter>
<Button onClick={apply} disabled={!validUrl}> <Button onClick={apply} disabled={!validUrl} name="apply">
Apply {t("button.apply")}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -1,4 +1,4 @@
import { useDevice } from "../../core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import { import {
Dialog, Dialog,
DialogClose, DialogClose,
@@ -9,9 +9,10 @@ import {
} from "../UI/Dialog.tsx"; } from "../UI/Dialog.tsx";
import type { Protobuf, Types } from "@meshtastic/core"; import type { Protobuf, Types } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { useTranslation } from "react-i18next";
export interface LocationResponseDialogProps { export interface LocationResponseDialogProps {
location: Types.PacketMetadata<Protobuf.Mesh.location> | undefined; location: Types.PacketMetadata<Protobuf.Mesh.Position> | undefined;
open: boolean; open: boolean;
onOpenChange: () => void; onOpenChange: () => void;
} }
@@ -21,40 +22,70 @@ export const LocationResponseDialog = ({
open, open,
onOpenChange, onOpenChange,
}: LocationResponseDialogProps) => { }: 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 ?? const longName = from?.user?.longName ??
(from ? `!${numberToHexUnpadded(from?.num)}` : "Unknown"); (from ? `!${numberToHexUnpadded(from?.num)}` : t("unknown.shortName"));
const shortName = from?.user?.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 ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent> <DialogContent>
<DialogClose /> <DialogClose />
<DialogHeader> <DialogHeader>
<DialogTitle>{`Location: ${longName} (${shortName})`}</DialogTitle> <DialogTitle>
{t("locationResponse.title", {
identifier: `${longName} (${shortName})`,
})}
</DialogTitle>
</DialogHeader> </DialogHeader>
<DialogDescription> <DialogDescription>
{hasCoordinates
? (
<div className="ml-5 flex"> <div className="ml-5 flex">
<span className="ml-4 border-l-2 border-l-backgroundPrimary pl-2 text-textPrimary"> <span className="ml-4 border-l-2 border-l-backgroundPrimary pl-2 text-textPrimary">
<p> <p>
Coordinates:{" "} {t("locationResponse.coordinates")}
<a <a
className="text-blue-500 dark:text-blue-400" className="text-blue-500 dark:text-blue-400"
href={`https://www.openstreetmap.org/?mlat=${location?.data.latitudeI / 1e7 href={`https://www.openstreetmap.org/?mlat=${
}&mlon=${location?.data.longitudeI / 1e7}&layers=N`} position.latitudeI ?? 0 / 1e7
}&mlon=${position.longitudeI ?? 0 / 1e7}&layers=N`}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
> >
{location?.data.latitudeI / 1e7},{" "} {" "}
{location?.data.longitudeI / 1e7} {position.latitudeI ?? 0 / 1e7},{" "}
{position.longitudeI ?? 0 / 1e7}
</a> </a>
</p> </p>
<p>Altitude: {location?.data.altitude}m</p> <p>
{t("locationResponse.altitude")} {position.altitude}
{(position.altitude ?? 0) < 1
? t("unit.meter.one")
: t("unit.meter.plural")}
</p>
</span> </span>
</div> </div>
)
: (
// Optional: Show a message if coordinates are not available
<p className="text-textPrimary">
{t("locationResponse.noCoordinates")}
</p>
)}
</DialogDescription> </DialogDescription>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -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>
);
};
+77 -54
View File
@@ -1,7 +1,7 @@
import { import {
type BrowserFeature, type BrowserFeature,
useBrowserFeatureDetection, useBrowserFeatureDetection,
} from "../../core/hooks/useBrowserFeatureDetection.ts"; } from "@core/hooks/useBrowserFeatureDetection.ts";
import { BLE } from "@components/PageComponents/Connect/BLE.tsx"; import { BLE } from "@components/PageComponents/Connect/BLE.tsx";
import { HTTP } from "@components/PageComponents/Connect/HTTP.tsx"; import { HTTP } from "@components/PageComponents/Connect/HTTP.tsx";
import { Serial } from "@components/PageComponents/Connect/Serial.tsx"; import { Serial } from "@components/PageComponents/Connect/Serial.tsx";
@@ -18,16 +18,16 @@ import {
TabsList, TabsList,
TabsTrigger, TabsTrigger,
} from "@components/UI/Tabs.tsx"; } from "@components/UI/Tabs.tsx";
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
import { AlertCircle } from "lucide-react"; import { AlertCircle } from "lucide-react";
import { Trans, useTranslation } from "react-i18next";
import { Link } from "../UI/Typography/Link.tsx"; import { Link } from "../UI/Typography/Link.tsx";
import { Fragment } from "react/jsx-runtime";
export interface TabElementProps { export interface TabElementProps {
closeDialog: () => void; closeDialog: () => void;
} }
export interface TabManifest { export interface TabManifest {
id: "HTTP" | "BLE" | "Serial";
label: string; label: string;
element: React.FC<TabElementProps>; element: React.FC<TabElementProps>;
isDisabled: boolean; isDisabled: boolean;
@@ -40,23 +40,28 @@ export interface NewDeviceProps {
interface FeatureErrorProps { interface FeatureErrorProps {
missingFeatures: BrowserFeature[]; missingFeatures: BrowserFeature[];
tabId: "HTTP" | "BLE" | "Serial";
} }
const links: { [key: string]: string } = { const errors: Record<BrowserFeature, { href: string; i18nKey: string }> = {
"Web Bluetooth": "Web Bluetooth": {
href:
"https://developer.mozilla.org/en-US/docs/Web/API/Web_Bluetooth_API#browser_compatibility", "https://developer.mozilla.org/en-US/docs/Web/API/Web_Bluetooth_API#browser_compatibility",
"Web Serial": i18nKey: "newDeviceDialog.validation.requiresWebBluetooth",
},
"Web Serial": {
href:
"https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API#browser_compatibility", "https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API#browser_compatibility",
"Secure Context": i18nKey: "newDeviceDialog.validation.requiresWebSerial",
},
"Secure Context": {
href:
"https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts", "https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts",
i18nKey: "newDeviceDialog.validation.requiresSecureContext",
},
}; };
const listFormatter = new Intl.ListFormat("en", { const ErrorMessage = ({ missingFeatures, tabId }: FeatureErrorProps) => {
style: "long",
type: "disjunction",
});
const ErrorMessage = ({ missingFeatures }: FeatureErrorProps) => {
if (missingFeatures.length === 0) return null; if (missingFeatures.length === 0) return null;
const browserFeatures = missingFeatures.filter( const browserFeatures = missingFeatures.filter(
@@ -64,46 +69,51 @@ const ErrorMessage = ({ missingFeatures }: FeatureErrorProps) => {
); );
const needsSecureContext = missingFeatures.includes("Secure Context"); const needsSecureContext = missingFeatures.includes("Secure Context");
const formatFeatureList = (features: string[]) => { const needsFeature =
const parts = listFormatter.formatToParts(features); (tabId === "BLE" && browserFeatures.includes("Web Bluetooth"))
return parts.map((part) => { ? "Web Bluetooth"
if (part.type === "element") { : (tabId === "Serial" && browserFeatures.includes("Web Serial"))
return ( ? "Web Serial"
<Link key={part.value} href={links[part.value]}> : undefined;
{part.value}
</Link>
);
}
return <Fragment key={part.value}>{part.value}</Fragment>;
});
};
return ( 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"> <div className="flex items-center gap-2 w-full">
<AlertCircle size={40} className="mr-2 shrink-0 text-white" /> <AlertCircle size={40} className="mr-2 shrink-0 text-white" />
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<p className="text-sm text-white"> <div className="text-sm text-white">
{browserFeatures.length > 0 && ( {needsFeature && (
<> <Trans
This connection type requires{" "} i18nKey={errors[needsFeature].i18nKey}
{formatFeatureList(browserFeatures)}. Please use a components={[
supported browser, like Chrome or Edge. <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 && ( {needsSecureContext && (
<> <Trans
{browserFeatures.length > 0 && " Additionally, it"} i18nKey={browserFeatures.length > 0
{browserFeatures.length === 0 && "This application"} requires a ? "newDeviceDialog.validation.additionallyRequiresSecureContext"
{" "} : "newDeviceDialog.validation.requiresSecureContext"}
<Link href={links["Secure Context"]}>secure context</Link>. components={{
Please connect using HTTPS or localhost. "0": (
</> <Link
href={errors["Secure Context"].href}
className="underline hover:text-slate-200"
/>
),
}}
/>
)} )}
</p>
</div> </div>
</div> </div>
</Subtle> </div>
</div>
); );
}; };
@@ -111,22 +121,26 @@ export const NewDeviceDialog = ({
open, open,
onOpenChange, onOpenChange,
}: NewDeviceProps) => { }: NewDeviceProps) => {
const { t } = useTranslation("dialog");
const { unsupported } = useBrowserFeatureDetection(); const { unsupported } = useBrowserFeatureDetection();
const tabs: TabManifest[] = [ const tabs: TabManifest[] = [
{ {
label: "HTTP", id: "HTTP",
label: t("newDeviceDialog.tabHttp"),
element: HTTP, element: HTTP,
isDisabled: false, isDisabled: false,
}, },
{ {
label: "Bluetooth", id: "BLE",
label: t("newDeviceDialog.tabBluetooth"),
element: BLE, element: BLE,
isDisabled: unsupported.includes("Web Bluetooth") || isDisabled: unsupported.includes("Web Bluetooth") ||
unsupported.includes("Secure Context"), unsupported.includes("Secure Context"),
}, },
{ {
label: "Serial", id: "Serial",
label: t("newDeviceDialog.tabSerial"),
element: Serial, element: Serial,
isDisabled: unsupported.includes("Web Serial") || isDisabled: unsupported.includes("Web Serial") ||
unsupported.includes("Secure Context"), unsupported.includes("Secure Context"),
@@ -135,26 +149,35 @@ export const NewDeviceDialog = ({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent> <DialogContent aria-describedby={undefined}>
<DialogClose /> <DialogClose />
<DialogHeader> <DialogHeader>
<DialogTitle>Connect New Device</DialogTitle> <DialogTitle>{t("newDeviceDialog.title")}</DialogTitle>
</DialogHeader> </DialogHeader>
<Tabs defaultValue="HTTP"> <Tabs defaultValue="HTTP">
<TabsList> <TabsList>
{tabs.map((tab) => ( {tabs.map((tab) => (
<TabsTrigger key={tab.label} value={tab.label}> <TabsTrigger key={tab.id} value={tab.id}>
{tab.label} {tab.label}
</TabsTrigger> </TabsTrigger>
))} ))}
</TabsList> </TabsList>
{tabs.map((tab) => ( {tabs.map((tab) => (
<TabsContent key={tab.label} value={tab.label}> <TabsContent key={tab.id} value={tab.id}>
<fieldset disabled={tab.isDisabled}> <fieldset disabled={tab.isDisabled}>
{tab.isDisabled {(tab.id !== "HTTP" &&
? <ErrorMessage missingFeatures={unsupported} /> tab.isDisabled)
: null} ? (
<tab.element closeDialog={() => onOpenChange(false)} /> <ErrorMessage
missingFeatures={unsupported}
tabId={tab.id}
/>
)
: (
<tab.element
closeDialog={() => onOpenChange(false)}
/>
)}
</fieldset> </fieldset>
</TabsContent> </TabsContent>
))} ))}
@@ -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();
});
});
@@ -1,5 +1,16 @@
import { useEffect, useState } from "react";
import { useAppStore } from "@core/stores/appStore.ts"; import { useAppStore } from "@core/stores/appStore.ts";
import { useDevice } from "@core/stores/deviceStore.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 { import {
Accordion, Accordion,
AccordionContent, AccordionContent,
@@ -14,11 +25,26 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@components/UI/Dialog.tsx"; } from "@components/UI/Dialog.tsx";
import { Protobuf } from "@meshtastic/core"; import { Button } from "@components/UI/Button.tsx";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; import {
import { DeviceImage } from "@components/generic/DeviceImage.tsx"; BellIcon,
import { TimeAgo } from "@components/generic/TimeAgo.tsx"; BellOffIcon,
import { Uptime } from "@components/generic/Uptime.tsx"; 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 { export interface NodeDetailsDialogProps {
open: boolean; open: boolean;
@@ -29,110 +55,287 @@ export const NodeDetailsDialog = ({
open, open,
onOpenChange, onOpenChange,
}: NodeDetailsDialogProps) => { }: NodeDetailsDialogProps) => {
const { nodes } = useDevice(); const { t } = useTranslation("dialog");
const { nodeNumDetails } = useAppStore(); 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 = [ const deviceMetricsMap = [
{ {
key: "airUtilTx", key: "airUtilTx",
label: "Air TX utilization", label: t("nodeDetails.airTxUtilization"),
value: device.deviceMetrics?.airUtilTx, value: node.deviceMetrics?.airUtilTx,
format: (val: number) => `${val.toFixed(2)}%`, format: (val: number) => `${val.toFixed(2)}%`,
}, },
{ {
key: "channelUtilization", key: "channelUtilization",
label: "Channel utilization", label: t("nodeDetails.channelUtilization"),
value: device.deviceMetrics?.channelUtilization, value: node.deviceMetrics?.channelUtilization,
format: (val: number) => `${val.toFixed(2)}%`, format: (val: number) => `${val.toFixed(2)}%`,
}, },
{ {
key: "batteryLevel", key: "batteryLevel",
label: "Battery level", label: t("nodeDetails.batteryLevel"),
value: device.deviceMetrics?.batteryLevel, value: node.deviceMetrics?.batteryLevel,
format: (val: number) => `${val.toFixed(2)}%`, format: (val: number) => `${val.toFixed(2)}%`,
}, },
{ {
key: "voltage", key: "voltage",
label: "Voltage", label: t("nodeDetails.voltage"),
value: device.deviceMetrics?.voltage, value: node.deviceMetrics?.voltage,
format: (val: number) => `${val.toFixed(2)}V`, format: (val: number) => `${val.toFixed(2)}V`,
}, },
]; ];
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent > <DialogContent aria-describedby={undefined}>
<DialogClose /> <DialogClose />
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
Node Details for {device.user?.longName ?? "UNKNOWN"} ( {t("nodeDetails.title", {
{device.user?.shortName ?? "UNK"}) identifier: `${node.user?.longName ?? t("unknown.shortName")} (${
node.user?.shortName ?? t("unknown.shortName")
})`,
})}
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<div className="w-full"> <div className="w-full">
<div className="flex flex-col"> <div className="flex flex-row flex-wrap space-y-1">
<DeviceImage <Button
className="w-32 h-32 mx-auto rounded-lg border-4 border-slate-200 dark:border-slate-800" className="mr-1"
deviceType={ name="message"
Protobuf.Mesh.HardwareModel[device.user?.hwModel ?? 0] 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" : "",
)}
/> />
<div className="bg-slate-100 text-slate-900 dark:text-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3"> </Button>
<p className="text-lg font-semibold">Details:</p> <div className="flex flex-1 justify-start"></div>
<p>
Hardware:{" "} <TooltipProvider delayDuration={300}>
{Protobuf.Mesh.HardwareModel[device.user?.hwModel ?? 0]} <Tooltip>
</p> <TooltipTrigger asChild>
<p>Node Number: {device.num}</p> <Button
<p>Node Hex: !{numberToHexUnpadded(device.num)}</p> className={cn(
<p> "flex justify-end mr-1 text-white",
Role:{" "} isIgnoredState
{ ? "bg-red-500 dark:bg-red-500 hover:bg-red-600 hover:dark:bg-red-600 text-white dark:text-white"
Protobuf.Config.Config_DeviceConfig_Role[ : "",
device.user?.role ?? 0 )}
] onClick={handleToggleIgnored}
} >
</p> {isIgnoredState ? <BellIcon /> : <BellOffIcon />}
<p> </Button>
Last Heard:{" "} </TooltipTrigger>
{device.lastHeard === 0 ? "Never" : <TimeAgo timestamp={device.lastHeard * 1000} />} <TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs">
</p> {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> </div>
{device.position && ( <Separator className="mt-5 mb-2" />
<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> <div className="flex flex-col flex-wrap space-x-1 space-y-1">
{device.position.latitudeI && device.position.longitudeI && ( <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> <p>
Coordinates:{" "} {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 <a
className="text-blue-500 dark:text-blue-400" className="text-blue-500 dark:text-blue-400"
href={`https://www.openstreetmap.org/?mlat=${device.position.latitudeI / 1e7 href={`https://www.openstreetmap.org/?mlat=${
}&mlon=${device.position.longitudeI / 1e7 node.position.latitudeI / 1e7
}&layers=N`} }&mlon=${node.position.longitudeI / 1e7}&layers=N`}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
> >
{device.position.latitudeI / 1e7},{" "} {node.position.latitudeI / 1e7},{" "}
{device.position.longitudeI / 1e7} {node.position.longitudeI / 1e7}
</a> </a>
</p> </p>
)} )}
{device.position.altitude && ( {node.position.altitude && (
<p>Altitude: {device.position.altitude}m</p> <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> </div>
)}
{device.deviceMetrics && ( {node.deviceMetrics && (
<div className="text-slate-900 dark:text-slate-100 bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3"> <div className="text-slate-900 dark:text-slate-100 bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
<p className="text-lg font-semibold text-slate-900 dark:text-slate-50"> <p className="text-lg font-semibold text-slate-900 dark:text-slate-100">
Device Metrics: {t("nodeDetails.deviceMetrics")}
</p> </p>
{deviceMetricsMap.map( {deviceMetricsMap.map(
(metric) => (metric) =>
@@ -140,17 +343,16 @@ export const NodeDetailsDialog = ({
<p key={metric.key}> <p key={metric.key}>
{metric.label}: {metric.format(metric.value)} {metric.label}: {metric.format(metric.value)}
</p> </p>
) ),
)} )}
{device.deviceMetrics.uptimeSeconds && ( {node.deviceMetrics.uptimeSeconds && (
<p> <p>
Uptime:{" "} {t("nodeDetails.uptime")}
<Uptime seconds={device.deviceMetrics.uptimeSeconds} /> <Uptime seconds={node.deviceMetrics.uptimeSeconds} />
</p> </p>
)} )}
</div> </div>
)} )}
</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"> <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"> <AccordionItem className="AccordionItem" value="item-1">
<AccordionTrigger> <AccordionTrigger>
<p className="text-lg font-semibold text-slate-900 dark:text-slate-50"> <p className="text-lg font-semibold text-slate-900 dark:text-slate-50">
All Raw Metrics: {t("nodeDetails.allRawMetrics")}
</p> </p>
</AccordionTrigger> </AccordionTrigger>
<AccordionContent className="overflow-x-scroll"> <AccordionContent className="overflow-x-scroll">
<pre className="text-xs w-full"> <pre className="text-xs w-full">
{JSON.stringify(device, null, 2)} {JSON.stringify(node, null, 2)}
</pre> </pre>
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
-117
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>
);
};
+36 -21
View File
@@ -12,6 +12,7 @@ import {
import { fromByteArray } from "base64-js"; import { fromByteArray } from "base64-js";
import { DownloadIcon, PrinterIcon } from "lucide-react"; import { DownloadIcon, PrinterIcon } from "lucide-react";
import React from "react"; import React from "react";
import { useTranslation } from "react-i18next";
export interface PkiBackupDialogProps { export interface PkiBackupDialogProps {
open: boolean; open: boolean;
@@ -22,7 +23,8 @@ export const PkiBackupDialog = ({
open, open,
onOpenChange, onOpenChange,
}: PkiBackupDialogProps) => { }: PkiBackupDialogProps) => {
const { config, setDialogOpen } = useDevice(); const { t } = useTranslation("dialog");
const { config, setDialogOpen, getMyNode } = useDevice();
const privateKey = config.security?.privateKey; const privateKey = config.security?.privateKey;
const publicKey = config.security?.publicKey; const publicKey = config.security?.publicKey;
@@ -46,7 +48,12 @@ export const PkiBackupDialog = ({
printWindow.document.write(` printWindow.document.write(`
<html> <html>
<head> <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> <style>
body { font-family: Arial, sans-serif; padding: 20px; } body { font-family: Arial, sans-serif; padding: 20px; }
h1 { font-size: 18px; } h1 { font-size: 18px; }
@@ -54,14 +61,18 @@ export const PkiBackupDialog = ({
</style> </style>
</head> </head>
<body> <body>
<h1>=== MESHTASTIC KEYS ===</h1> <h1>${
<br> t("pkiBackup.header", {
<h2>Public Key:</h2> 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> <p>${decodeKeyData(publicKey)}</p>
<h2>Private Key:</h2> <h3>${t("pkiBackup.privateKey")}</h3>
<p>${decodeKeyData(privateKey)}</p> <p>${decodeKeyData(privateKey)}</p>
<br> <p>${t("pkiBackup.footer")}</p>
<p>=== END OF KEYS ===</p>
</body> </body>
</html> </html>
`); `);
@@ -69,7 +80,7 @@ export const PkiBackupDialog = ({
printWindow.print(); printWindow.print();
closeDialog(); closeDialog();
} }
}, [decodeKeyData, privateKey, publicKey, closeDialog]); }, [decodeKeyData, privateKey, publicKey, closeDialog, t]);
const createDownloadKeyFile = React.useCallback(() => { const createDownloadKeyFile = React.useCallback(() => {
if (!privateKey || !publicKey) return; if (!privateKey || !publicKey) return;
@@ -78,12 +89,12 @@ export const PkiBackupDialog = ({
const decodedPublicKey = decodeKeyData(publicKey); const decodedPublicKey = decodeKeyData(publicKey);
const formattedContent = [ const formattedContent = [
"=== MESHTASTIC KEYS ===\n\n", `${t("pkiBackup.header")}\n\n`,
"Private Key:\n", `${t("pkiBackup.privateKey")}\n`,
decodedPrivateKey, decodedPrivateKey,
"\n\nPublic Key:\n", `\n\n${t("pkiBackup.publicKey")}\n`,
decodedPublicKey, decodedPublicKey,
"\n\n=== END OF KEYS ===", `\n\n${t("pkiBackup.footer")}`,
].join(""); ].join("");
const blob = new Blob([formattedContent], { type: "text/plain" }); const blob = new Blob([formattedContent], { type: "text/plain" });
@@ -91,43 +102,47 @@ export const PkiBackupDialog = ({
const link = document.createElement("a"); const link = document.createElement("a");
link.href = url; 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"; link.style.display = "none";
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
document.body.removeChild(link); document.body.removeChild(link);
closeDialog(); closeDialog();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}, [decodeKeyData, privateKey, publicKey, closeDialog]); }, [decodeKeyData, privateKey, publicKey, closeDialog, t]);
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent> <DialogContent>
<DialogClose /> <DialogClose />
<DialogHeader> <DialogHeader>
<DialogTitle>Backup Keys</DialogTitle> <DialogTitle>{t("pkiBackup.title")}</DialogTitle>
<DialogDescription> <DialogDescription>
Its important to backup your public and private keys and store your {t("pkiBackup.secureBackup")}
backup securely!
</DialogDescription> </DialogDescription>
<DialogDescription> <DialogDescription>
<span className="font-bold break-before-auto"> <span className="font-bold break-before-auto">
If you lose your keys, you will need to reset your device. {t("pkiBackup.loseKeysWarning")}
</span> </span>
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter className="mt-6"> <DialogFooter className="mt-6">
<Button <Button
variant="default" variant="default"
name="download"
onClick={() => createDownloadKeyFile()} onClick={() => createDownloadKeyFile()}
className="" className=""
> >
<DownloadIcon size={20} className="mr-2" /> <DownloadIcon size={20} className="mr-2" />
Download {t("button.download")}
</Button> </Button>
<Button variant="default" onClick={() => renderPrintWindow()}> <Button variant="default" onClick={() => renderPrintWindow()}>
<PrinterIcon size={20} className="mr-2" /> <PrinterIcon size={20} className="mr-2" />
Print {t("button.print")}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
+26 -4
View File
@@ -8,31 +8,53 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@components/UI/Dialog.tsx"; } from "@components/UI/Dialog.tsx";
import { useTranslation } from "react-i18next";
export interface PkiRegenerateDialogProps { export interface PkiRegenerateDialogProps {
text: {
title: string;
description: string;
button: string;
};
open: boolean; open: boolean;
onOpenChange: () => void; onOpenChange: () => void;
onSubmit: () => void; onSubmit: () => void;
} }
export const PkiRegenerateDialog = ({ export const PkiRegenerateDialog = ({
text = {
title: "",
description: "",
button: "",
},
open, open,
onOpenChange, onOpenChange,
onSubmit, onSubmit,
}: PkiRegenerateDialogProps) => { }: 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 ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent> <DialogContent>
<DialogClose /> <DialogClose />
<DialogHeader> <DialogHeader>
<DialogTitle>Regenerate Key pair?</DialogTitle> <DialogTitle>{dialogText.title}</DialogTitle>
<DialogDescription> <DialogDescription>
Are you sure you want to regenerate key pair? {dialogText.description}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<Button variant="destructive" onClick={() => onSubmit()}> <Button
Regenerate variant="destructive"
name="regenerate"
onClick={() => onSubmit()}
>
{dialogText.button}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
+21 -18
View File
@@ -13,9 +13,9 @@ import { Input } from "@components/UI/Input.tsx";
import { Label } from "@components/UI/Label.tsx"; import { Label } from "@components/UI/Label.tsx";
import { Protobuf, type Types } from "@meshtastic/core"; import { Protobuf, type Types } from "@meshtastic/core";
import { fromByteArray } from "base64-js"; import { fromByteArray } from "base64-js";
import { ClipboardIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { QRCode } from "react-qrcode-logo"; import { QRCode } from "react-qrcode-logo";
import { useTranslation } from "react-i18next";
export interface QRDialogProps { export interface QRDialogProps {
open: boolean; open: boolean;
@@ -30,6 +30,7 @@ export const QRDialog = ({
loraConfig, loraConfig,
channels, channels,
}: QRDialogProps) => { }: QRDialogProps) => {
const { t } = useTranslation("dialog");
const [selectedChannels, setSelectedChannels] = useState<number[]>([0]); const [selectedChannels, setSelectedChannels] = useState<number[]>([0]);
const [qrCodeUrl, setQrCodeUrl] = useState<string>(""); const [qrCodeUrl, setQrCodeUrl] = useState<string>("");
const [qrCodeAdd, setQrCodeAdd] = useState<boolean>(); const [qrCodeAdd, setQrCodeAdd] = useState<boolean>();
@@ -65,9 +66,9 @@ export const QRDialog = ({
<DialogContent> <DialogContent>
<DialogClose /> <DialogClose />
<DialogHeader> <DialogHeader>
<DialogTitle>Generate QR Code</DialogTitle> <DialogTitle>{t("qr.title")}</DialogTitle>
<DialogDescription> <DialogDescription>
The current LoRa configuration will also be shared. {t("qr.description")}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
@@ -79,13 +80,18 @@ export const QRDialog = ({
{channel.settings?.name.length {channel.settings?.name.length
? channel.settings.name ? channel.settings.name
: channel.role === Protobuf.Channel.Channel_Role.PRIMARY : channel.role === Protobuf.Channel.Channel_Role.PRIMARY
? "Primary" ? t("page.broadcastLabel", { ns: "channels" })
: `Channel: ${channel.index}`} : `${
t("page.channelIndex", {
ns: "channels",
index: channel.index,
})
}${channel.index}`}
</Label> </Label>
<Checkbox <Checkbox
key={channel.index} key={channel.index}
checked={selectedChannels.includes(channel.index)} checked={selectedChannels.includes(channel.index)}
onCheckedChange={() => { onChange={() => {
if (selectedChannels.includes(channel.index)) { if (selectedChannels.includes(channel.index)) {
setSelectedChannels( setSelectedChannels(
selectedChannels.filter((c) => selectedChannels.filter((c) =>
@@ -108,38 +114,35 @@ export const QRDialog = ({
<div className="flex justify-center"> <div className="flex justify-center">
<button <button
type="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 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-green-800 bg-green-800 text-white"
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600" : "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
}`} }`}
name="addChannels"
onClick={() => setQrCodeAdd(true)} onClick={() => setQrCodeAdd(true)}
> >
Add Channels {t("qr.addChannels")}
</button> </button>
<button <button
type="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 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-green-800 bg-green-800 text-white"
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600" : "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
}`} }`}
name="replaceChannels"
onClick={() => setQrCodeAdd(false)} onClick={() => setQrCodeAdd(false)}
> >
Replace Channels {t("qr.replaceChannels")}
</button> </button>
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Label>Sharable URL</Label> <Label>{t("qr.sharableUrl")}</Label>
<Input <Input
value={qrCodeUrl} value={qrCodeUrl}
disabled disabled
className="dark:text-slate-900"
action={{
icon: ClipboardIcon,
onClick() {
void navigator.clipboard.writeText(qrCodeUrl);
},
}}
/> />
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
+9 -10
View File
@@ -9,7 +9,8 @@ import {
} from "@components/UI/Dialog.tsx"; } from "@components/UI/Dialog.tsx";
import { Input } from "@components/UI/Input.tsx"; import { Input } from "@components/UI/Input.tsx";
import { useDevice } from "@core/stores/deviceStore.ts"; 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"; import { useState } from "react";
export interface RebootDialogProps { export interface RebootDialogProps {
@@ -21,6 +22,7 @@ export const RebootDialog = ({
open, open,
onOpenChange, onOpenChange,
}: RebootDialogProps) => { }: RebootDialogProps) => {
const { t } = useTranslation("dialog");
const { connection } = useDevice(); const { connection } = useDevice();
const [time, setTime] = useState<number>(5); const [time, setTime] = useState<number>(5);
@@ -30,9 +32,11 @@ export const RebootDialog = ({
<DialogContent> <DialogContent>
<DialogClose /> <DialogClose />
<DialogHeader> <DialogHeader>
<DialogTitle>Schedule Reboot</DialogTitle> <DialogTitle>
{t("reboot.title")}
</DialogTitle>
<DialogDescription> <DialogDescription>
Reboot the connected node after x minutes. {t("reboot.description")}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex gap-2 p-4"> <div className="flex gap-2 p-4">
@@ -41,21 +45,16 @@ export const RebootDialog = ({
className="dark:text-slate-900" className="dark:text-slate-900"
value={time} value={time}
onChange={(e) => setTime(Number.parseInt(e.target.value))} onChange={(e) => setTime(Number.parseInt(e.target.value))}
action={{
icon: ClockIcon,
onClick() {
connection?.reboot(time * 60).then(() => onOpenChange(false));
},
}}
/> />
<Button <Button
className="w-24" className="w-24"
name="now"
onClick={() => { onClick={() => {
connection?.reboot(2).then(() => onOpenChange(false)); connection?.reboot(2).then(() => onOpenChange(false));
}} }}
> >
<RefreshCwIcon className="mr-2" size={16} /> <RefreshCwIcon className="mr-2" size={16} />
Now {t("button.now")}
</Button> </Button>
</div> </div>
</DialogContent> </DialogContent>
+51 -32
View File
@@ -1,48 +1,69 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { RebootOTADialog } from './RebootOTADialog.tsx'; import { RebootOTADialog } from "./RebootOTADialog.tsx";
import { ReactNode } from "react"; import {
ButtonHTMLAttributes,
ClassAttributes,
InputHTMLAttributes,
ReactNode,
} from "react";
import { JSX } from "react/jsx-runtime";
const rebootOtaMock = vi.fn(); const rebootOtaMock = vi.fn();
let mockConnection: { rebootOta: (delay: number) => void } | undefined = { let mockConnection: { rebootOta: (delay: number) => void } | undefined = {
rebootOta: rebootOtaMock, rebootOta: rebootOtaMock,
}; };
vi.mock('@core/stores/deviceStore.ts', () => ({ vi.mock("@core/stores/deviceStore.ts", () => ({
useDevice: () => ({ useDevice: () => ({
connection: mockConnection, connection: mockConnection,
}), }),
})); }));
vi.mock('@components/UI/Button.tsx', async () => { vi.mock("@components/UI/Button.tsx", async () => {
const actual = await vi.importActual('@components/UI/Button.tsx'); const actual = await vi.importActual("@components/UI/Button.tsx");
return { return {
...actual, ...actual,
Button: (props: any) => <button {...props} />, Button: (
props:
& JSX.IntrinsicAttributes
& ClassAttributes<HTMLButtonElement>
& ButtonHTMLAttributes<HTMLButtonElement>,
) => <button {...props} />,
}; };
}); });
vi.mock('@components/UI/Input.tsx', async () => { vi.mock("@components/UI/Input.tsx", async () => {
const actual = await vi.importActual('@components/UI/Input.tsx'); const actual = await vi.importActual("@components/UI/Input.tsx");
return { return {
...actual, ...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 { return {
Dialog: ({ children }: { children: ReactNode }) => <div>{children}</div>, Dialog: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>, DialogContent: ({ children }: { children: ReactNode }) => (
DialogHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>, <div>{children}</div>
),
DialogHeader: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
DialogTitle: ({ children }: { children: ReactNode }) => <h1>{children}</h1>, DialogTitle: ({ children }: { children: ReactNode }) => <h1>{children}</h1>,
DialogDescription: ({ children }: { children: ReactNode }) => <p>{children}</p>, DialogDescription: ({ children }: { children: ReactNode }) => (
<p>{children}</p>
),
DialogClose: () => null, DialogClose: () => null,
}; };
}); });
describe("RebootOTADialog", () => {
describe('RebootOTADialog', () => {
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers(); vi.useFakeTimers();
rebootOtaMock.mockClear(); rebootOtaMock.mockClear();
@@ -52,22 +73,23 @@ describe('RebootOTADialog', () => {
vi.useRealTimers(); vi.useRealTimers();
}); });
it('renders dialog with default input value', () => { it("renders dialog with default input value", () => {
render(<RebootOTADialog open={true} onOpenChange={() => { }} />); render(<RebootOTADialog open onOpenChange={() => {}} />);
expect(screen.getByPlaceholderText(/enter delay/i)).toHaveValue(5); 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(); 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(); const onOpenChangeMock = vi.fn();
render(<RebootOTADialog open={true} onOpenChange={onOpenChangeMock} />); render(<RebootOTADialog open onOpenChange={onOpenChangeMock} />);
fireEvent.change(screen.getByPlaceholderText(/enter delay/i), { 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(); 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(); 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)); 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(); const onOpenChangeMock = vi.fn();
// simulate no connection
mockConnection = undefined; 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); vi.advanceTimersByTime(5000);
await waitFor(() => { await waitFor(() => {
@@ -107,8 +128,6 @@ describe('RebootOTADialog', () => {
expect(onOpenChangeMock).not.toHaveBeenCalled(); expect(onOpenChangeMock).not.toHaveBeenCalled();
}); });
// reset connection for other tests
mockConnection = { rebootOta: rebootOtaMock }; mockConnection = { rebootOta: rebootOtaMock };
}); });
}); });
+24 -12
View File
@@ -11,6 +11,7 @@ import {
} from "@components/UI/Dialog.tsx"; } from "@components/UI/Dialog.tsx";
import { Input } from "@components/UI/Input.tsx"; import { Input } from "@components/UI/Input.tsx";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import { useTranslation } from "react-i18next";
export interface RebootOTADialogProps { export interface RebootOTADialogProps {
open: boolean; open: boolean;
@@ -19,7 +20,10 @@ export interface RebootOTADialogProps {
const DEFAULT_REBOOT_DELAY = 5; // seconds 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 { connection } = useDevice();
const [time, setTime] = useState<number>(DEFAULT_REBOOT_DELAY); const [time, setTime] = useState<number>(DEFAULT_REBOOT_DELAY);
const [isScheduled, setIsScheduled] = useState(false); const [isScheduled, setIsScheduled] = useState(false);
@@ -28,8 +32,8 @@ export const RebootOTADialog = ({ open, onOpenChange }: RebootOTADialogProps) =>
const handleSetTime = (e: React.ChangeEvent<HTMLInputElement>) => { const handleSetTime = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.validity.valid) { if (!e.target.validity.valid) {
e.preventDefault(); e.preventDefault();
return return;
}; }
const val = e.target.value; const val = e.target.value;
setInputValue(val); setInputValue(val);
@@ -48,7 +52,6 @@ export const RebootOTADialog = ({ open, onOpenChange }: RebootOTADialogProps) =>
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
setTimeout(() => { setTimeout(() => {
console.log("Rebooting...");
resolve(); resolve();
}, delay * 1000); }, delay * 1000);
}).finally(() => { }).finally(() => {
@@ -71,9 +74,11 @@ export const RebootOTADialog = ({ open, onOpenChange }: RebootOTADialogProps) =>
<DialogContent> <DialogContent>
<DialogClose /> <DialogClose />
<DialogHeader> <DialogHeader>
<DialogTitle>Reboot to OTA Mode</DialogTitle> <DialogTitle>
{t("rebootOta.title")}
</DialogTitle>
<DialogDescription> <DialogDescription>
Reboot the connected node after a delay into OTA (Over-the-Air) mode. {t("rebootOta.description")}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -85,20 +90,27 @@ export const RebootOTADialog = ({ open, onOpenChange }: RebootOTADialogProps) =>
className="dark:text-slate-900 appearance-none" className="dark:text-slate-900 appearance-none"
value={inputValue} value={inputValue}
onChange={handleSetTime} 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} /> <ClockIcon className="mr-2" size={18} />
{isScheduled ? 'Reboot has been scheduled' : 'Schedule Reboot'} {isScheduled ? t("rebootOta.scheduled") : t("rebootOta.title")}
</Button> </Button>
</div> </div>
<Button variant="destructive" onClick={() => handleInstantReboot()}> <Button
variant="destructive"
name="rebootNow"
onClick={() => handleInstantReboot()}
>
<RefreshCwIcon className="mr-2" size={16} /> <RefreshCwIcon className="mr-2" size={16} />
Reboot to OTA Mode Now {t("button.rebootOtaNow")}
</Button> </Button>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
}; };
@@ -1,55 +1,49 @@
import { render, screen, fireEvent } from "@testing-library/react"; import { render } from "@testing-library/react";
import { beforeEach, describe, expect, it, Mock, vi } from "vitest"; import { DeviceContext, useDeviceStore } from "@core/stores/deviceStore.ts";
import { RefreshKeysDialog } from "./RefreshKeysDialog"; import { RefreshKeysDialog } from "./RefreshKeysDialog.tsx";
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts"; import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
vi.mock("./useRefreshKeysDialog.ts", () => ({ vi.mock("@core/stores/messageStore");
useRefreshKeysDialog: vi.fn(), vi.mock("./useRefreshKeysDialog");
}));
describe("RefreshKeysDialog Component", () => { const mockUseMessageStore = vi.mocked(useMessageStore);
let handleCloseDialogMock: Mock; const mockUseRefreshKeysDialog = vi.mocked(useRefreshKeysDialog);
let handleNodeRemoveMock: Mock;
let onOpenChangeMock: Mock;
beforeEach(() => { const getInitialState = () =>
handleCloseDialogMock = vi.fn(); useDeviceStore.getInitialState?.() ??
handleNodeRemoveMock = vi.fn(); { devices: new Map(), remoteDevices: new Map() };
onOpenChangeMock = vi.fn();
(useRefreshKeysDialog as Mock).mockReturnValue({ beforeEach(() => {
handleCloseDialog: handleCloseDialogMock, useDeviceStore.setState(getInitialState(), true);
handleNodeRemove: handleNodeRemoveMock, vi.clearAllMocks();
}); });
});
afterEach(() => {
vi.restoreAllMocks();
});
test("does not render dialog if no error exists for active chat", () => {
const deviceId = 1;
const activeChatNum = 54321;
it("renders the dialog with correct content", () => { useDeviceStore.getState().addDevice(deviceId);
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", () => { const currentDeviceState = useDeviceStore.getState().getDevice(deviceId);
render(<RefreshKeysDialog open={true} onOpenChange={onOpenChangeMock} />); if (!currentDeviceState) throw new Error("Device not found");
fireEvent.click(screen.getByText("Request New Keys"));
expect(handleNodeRemoveMock).toHaveBeenCalled();
});
it("calls handleCloseDialog when 'Dismiss' button is clicked", () => { mockUseMessageStore.mockReturnValue({ activeChat: activeChatNum });
render(<RefreshKeysDialog open={true} onOpenChange={onOpenChangeMock} />); mockUseRefreshKeysDialog.mockReturnValue({
fireEvent.click(screen.getByText("Dismiss")); handleCloseDialog: vi.fn(),
expect(handleCloseDialogMock).toHaveBeenCalled(); handleNodeRemove: vi.fn(),
}); });
it("calls onOpenChange when dialog close button is clicked", () => { const { container } = render(
render(<RefreshKeysDialog open={true} onOpenChange={onOpenChangeMock} />); <DeviceContext.Provider value={currentDeviceState}>
fireEvent.click(screen.getByRole("button", { name: /close/i })); <RefreshKeysDialog open onOpenChange={vi.fn()} />
expect(handleCloseDialogMock).toHaveBeenCalled(); </DeviceContext.Provider>,
}); );
it("does not render when open is false", () => { expect(container.firstChild).toBeNull();
render(<RefreshKeysDialog open={false} onOpenChange={onOpenChangeMock} />);
expect(screen.queryByText("Keys Mismatch")).not.toBeInTheDocument();
});
}); });
@@ -8,54 +8,88 @@ import {
import { Button } from "@components/UI/Button.tsx"; import { Button } from "@components/UI/Button.tsx";
import { LockKeyholeOpenIcon } from "lucide-react"; import { LockKeyholeOpenIcon } from "lucide-react";
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts"; 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 { export interface RefreshKeysDialogProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; 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 { 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 ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <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} /> <DialogClose onClick={handleCloseDialog} />
<DialogHeader> <DialogHeader>
<DialogTitle>Keys Mismatch</DialogTitle> <DialogTitle>{text.title}</DialogTitle>
</DialogHeader> </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"> <ul className="mt-2">
<li className="flex place-items-center gap-2 items-start"> <li className="flex place-items-center gap-2 items-start">
<div className="p-2 bg-slate-500 rounded-lg mt-1"> <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>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div> <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> <p>
This will remove the node from device and request new keys. {t("refreshKeys.description.acceptNewKeys")}
</p> </p>
</div> </div>
<Button <Button
variant="default" variant="default"
name="requestNewKeys"
onClick={handleNodeRemove} onClick={handleNodeRemove}
className=""
> >
Request New Keys {t("button.requestNewKeys")}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
name="dismiss"
onClick={handleCloseDialog} onClick={handleCloseDialog}
className=""
> >
Dismiss {t("button.dismiss")}
</Button> </Button>
</div> </div>
</li> </li>
</ul> </ul>
{/* </DialogDescription> */}
</DialogContent> </DialogContent>
</Dialog > </Dialog>
); );
}; };
@@ -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);
});
});
@@ -1,10 +1,15 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import { useMessageStore } from "@core/stores/messageStore/index.ts";
export function useRefreshKeysDialog() { export function useRefreshKeysDialog() {
const { removeNode, setDialogOpen, clearNodeError, getNodeError } = useDevice(); const { removeNode, setDialogOpen, clearNodeError, getNodeError } =
const { activeChat } = useAppStore(); useDevice();
const { activeChat } = useMessageStore();
const handleCloseDialog = useCallback(() => {
setDialogOpen("refreshKeys", false);
}, [setDialogOpen]);
const handleNodeRemove = useCallback(() => { const handleNodeRemove = useCallback(() => {
const nodeWithError = getNodeError(activeChat); const nodeWithError = getNodeError(activeChat);
@@ -12,17 +17,12 @@ export function useRefreshKeysDialog() {
return; return;
} }
clearNodeError(activeChat); clearNodeError(activeChat);
handleCloseDialog();; handleCloseDialog();
return removeNode(nodeWithError?.node); return removeNode(nodeWithError?.node);
}, [activeChat, clearNodeError, setDialogOpen, removeNode]); }, [activeChat, clearNodeError, getNodeError, removeNode, handleCloseDialog]);
const handleCloseDialog = useCallback(() => {
setDialogOpen('refreshKeys', false);
}, [setDialogOpen])
return { return {
handleCloseDialog, handleCloseDialog,
handleNodeRemove handleNodeRemove,
}; };
} }
+12 -6
View File
@@ -11,6 +11,7 @@ import {
DialogTitle, DialogTitle,
} from "@components/UI/Dialog.tsx"; } from "@components/UI/Dialog.tsx";
import { Label } from "@components/UI/Label.tsx"; import { Label } from "@components/UI/Label.tsx";
import { useTranslation } from "react-i18next";
export interface RemoveNodeDialogProps { export interface RemoveNodeDialogProps {
open: boolean; open: boolean;
@@ -21,7 +22,8 @@ export const RemoveNodeDialog = ({
open, open,
onOpenChange, onOpenChange,
}: RemoveNodeDialogProps) => { }: RemoveNodeDialogProps) => {
const { connection, nodes, removeNode } = useDevice(); const { t } = useTranslation("dialog");
const { connection, getNode, removeNode } = useDevice();
const { nodeNumToBeRemoved } = useAppStore(); const { nodeNumToBeRemoved } = useAppStore();
const onSubmit = () => { const onSubmit = () => {
@@ -35,19 +37,23 @@ export const RemoveNodeDialog = ({
<DialogContent> <DialogContent>
<DialogClose /> <DialogClose />
<DialogHeader> <DialogHeader>
<DialogTitle>Remove Node?</DialogTitle> <DialogTitle>{t("removeNode.title")}</DialogTitle>
<DialogDescription> <DialogDescription>
Are you sure you want to remove this Node? {t("removeNode.description")}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="gap-4"> <div className="gap-4">
<form onSubmit={onSubmit}> <form onSubmit={onSubmit}>
<Label>{nodes.get(nodeNumToBeRemoved)?.user?.longName}</Label> <Label>{getNode(nodeNumToBeRemoved)?.user?.longName}</Label>
</form> </form>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="destructive" onClick={() => onSubmit()}> <Button
Remove variant="destructive"
name="remove"
onClick={() => onSubmit()}
>
{t("button.remove")}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
+9 -5
View File
@@ -10,6 +10,7 @@ import {
import { Input } from "@components/UI/Input.tsx"; import { Input } from "@components/UI/Input.tsx";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import { ClockIcon, PowerIcon } from "lucide-react"; import { ClockIcon, PowerIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useState } from "react"; import { useState } from "react";
export interface ShutdownDialogProps { export interface ShutdownDialogProps {
@@ -21,6 +22,7 @@ export const ShutdownDialog = ({
open, open,
onOpenChange, onOpenChange,
}: ShutdownDialogProps) => { }: ShutdownDialogProps) => {
const { t } = useTranslation("dialog");
const { connection } = useDevice(); const { connection } = useDevice();
const [time, setTime] = useState<number>(5); const [time, setTime] = useState<number>(5);
@@ -30,9 +32,11 @@ export const ShutdownDialog = ({
<DialogContent> <DialogContent>
<DialogClose /> <DialogClose />
<DialogHeader> <DialogHeader>
<DialogTitle>Schedule Shutdown</DialogTitle> <DialogTitle>
{t("shutdown.title")}
</DialogTitle>
<DialogDescription> <DialogDescription>
Turn off the connected node after x minutes. {t("shutdown.description")}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -41,8 +45,7 @@ export const ShutdownDialog = ({
type="number" type="number"
value={time} value={time}
onChange={(e) => setTime(Number.parseInt(e.target.value))} onChange={(e) => setTime(Number.parseInt(e.target.value))}
className="dark:text-slate-900" suffix={t("unit.minute.plural")}
suffix="Minutes"
/> />
<Button <Button
className="w-24" className="w-24"
@@ -54,12 +57,13 @@ export const ShutdownDialog = ({
</Button> </Button>
<Button <Button
className="w-24" className="w-24"
name="now"
onClick={() => { onClick={() => {
connection?.shutdown(2).then(() => () => onOpenChange(false)); connection?.shutdown(2).then(() => () => onOpenChange(false));
}} }}
> >
<PowerIcon className="mr-2" size={16} /> <PowerIcon className="mr-2" size={16} />
Now {t("button.now")}
</Button> </Button>
</div> </div>
</DialogContent> </DialogContent>
@@ -1,4 +1,4 @@
import { useDevice } from "../../core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import { import {
Dialog, Dialog,
DialogClose, DialogClose,
@@ -11,6 +11,7 @@ import type { Protobuf, Types } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { TraceRoute } from "../PageComponents/Messages/TraceRoute.tsx"; import { TraceRoute } from "../PageComponents/Messages/TraceRoute.tsx";
import { useTranslation } from "react-i18next";
export interface TracerouteResponseDialogProps { export interface TracerouteResponseDialogProps {
traceroute: Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery> | undefined; traceroute: Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery> | undefined;
@@ -23,30 +24,43 @@ export const TracerouteResponseDialog = ({
open, open,
onOpenChange, onOpenChange,
}: TracerouteResponseDialogProps) => { }: TracerouteResponseDialogProps) => {
const { nodes } = useDevice(); const { t } = useTranslation("dialog");
const { getNode } = useDevice();
const route: number[] = traceroute?.data.route ?? []; const route: number[] = traceroute?.data.route ?? [];
const routeBack: number[] = traceroute?.data.routeBack ?? []; const routeBack: number[] = traceroute?.data.routeBack ?? [];
const snrTowards = traceroute?.data.snrTowards ?? []; const snrTowards = (traceroute?.data.snrTowards ?? []).map((snr) => snr / 4);
const snrBack = traceroute?.data.snrBack ?? []; const snrBack = (traceroute?.data.snrBack ?? []).map((snr) => snr / 4);
const from = nodes.get(traceroute?.from ?? 0); const from = getNode(traceroute?.from ?? 0);
const longName = from?.user?.longName ?? const fromLongName = from?.user?.longName ??
(from ? `!${numberToHexUnpadded(from?.num)}` : "Unknown"); (from ? `!${numberToHexUnpadded(from?.num)}` : t("unknown.shortName"));
const shortName = from?.user?.shortName ?? const fromShortName = from?.user?.shortName ??
(from ? `${numberToHexUnpadded(from?.num).substring(0, 4)}` : "UNK"); (from
const to = nodes.get(traceroute?.to ?? 0); ? `${numberToHexUnpadded(from?.num).substring(0, 4)}`
: t("unknown.shortName"));
const toUser = getNode(traceroute?.to ?? 0);
if (!toUser || !from) {
return null;
}
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent> <DialogContent>
<DialogClose /> <DialogClose />
<DialogHeader> <DialogHeader>
<DialogTitle>{`Traceroute: ${longName} (${shortName})`}</DialogTitle> <DialogTitle>
{t("tracerouteResponse.title", {
identifier: `${fromLongName} (${fromShortName})`,
})}
</DialogTitle>
</DialogHeader> </DialogHeader>
<DialogDescription> <DialogDescription>
<TraceRoute <TraceRoute
route={route} route={route}
routeBack={routeBack} routeBack={routeBack}
from={from} from={{ user: from.user }}
to={to} to={{ user: toUser.user }}
snrTowards={snrTowards} snrTowards={snrTowards}
snrBack={snrBack} snrBack={snrBack}
/> />
@@ -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