├── .github
├── ISSUE_TEMPLATE
│ ├── bug.yml
│ └── feature.yml
├── pull_request_template.md
└── workflows
│ ├── ci.yml
│ ├── crowdin-download.yml
│ ├── crowdin-upload-sources.yml
│ ├── crowdin-upload-translations.yml
│ ├── nightly.yml
│ ├── pr.yml
│ ├── release.yml
│ └── update-stable-from-master.yml
├── .gitignore
├── .npmrc
├── .vscode
├── extensions.json
└── settings.json
├── LICENSE
├── README.md
├── crowdin.yml
├── deno.json
├── deno.lock
├── index.html
├── infra
├── .dockerignore
├── Containerfile
└── default.conf
├── package.json
├── postcss.config.cjs
├── public
├── Logo.svg
├── Logo_Black.svg
├── Logo_White.svg
├── apple-touch-icon.png
├── chirpy.svg
├── diy.svg
├── favicon.ico
├── heltec-ht62-esp32c3-sx1262.svg
├── heltec-mesh-node-t114-case.svg
├── heltec-mesh-node-t114.svg
├── heltec-v3-case.svg
├── heltec-v3.svg
├── heltec-vision-master-e213.svg
├── heltec-vision-master-e290.svg
├── heltec-vision-master-t190.svg
├── heltec-wireless-paper-V1_0.svg
├── heltec-wireless-paper.svg
├── heltec-wireless-tracker-V1-0.svg
├── heltec-wireless-tracker.svg
├── heltec-wsl-v3.svg
├── icon.svg
├── nano-g2-ultra.svg
├── pico.svg
├── promicro.svg
├── rak-wismeshtap.svg
├── rak11310.svg
├── rak2560.svg
├── rak4631.svg
├── rak4631_case.svg
├── robots.txt
├── rpipicow.svg
├── seeed-sensecap-indicator.svg
├── seeed-xiao-s3.svg
├── site.webmanifest
├── station-g2.svg
├── t-deck.svg
├── t-echo.svg
├── t-watch-s3.svg
├── tbeam-s3-core.svg
├── tbeam.svg
├── tlora-c6.svg
├── tlora-t3s3-epaper.svg
├── tlora-t3s3-v1.svg
├── tlora-v2-1-1_6.svg
├── tlora-v2-1-1_8.svg
├── tracker-t1000-e.svg
├── unknown.svg
├── wio-tracker-wm1110.svg
└── wm1110_dev_kit.svg
├── src
├── App.tsx
├── DeviceWrapper.tsx
├── __mocks__
│ ├── README.md
│ └── components
│ │ └── UI
│ │ ├── Button.tsx
│ │ ├── Checkbox.tsx
│ │ ├── Dialog
│ │ └── Dialog.tsx
│ │ ├── Label.tsx
│ │ └── Link.tsx
├── components
│ ├── BatteryStatus.tsx
│ ├── CommandPalette
│ │ └── index.tsx
│ ├── DeviceInfoPanel.tsx
│ ├── Dialog
│ │ ├── DeleteMessagesDialog
│ │ │ ├── DeleteMessagesDialog.test.tsx
│ │ │ └── DeleteMessagesDialog.tsx
│ │ ├── DeviceNameDialog.tsx
│ │ ├── DialogManager.tsx
│ │ ├── ImportDialog.tsx
│ │ ├── LocationResponseDialog.tsx
│ │ ├── NewDeviceDialog.tsx
│ │ ├── NodeDetailsDialog
│ │ │ ├── NodeDetailsDialog.test.tsx
│ │ │ └── NodeDetailsDialog.tsx
│ │ ├── PKIBackupDialog.tsx
│ │ ├── PkiRegenerateDialog.tsx
│ │ ├── QRDialog.tsx
│ │ ├── RebootDialog.tsx
│ │ ├── RebootOTADialog.test.tsx
│ │ ├── RebootOTADialog.tsx
│ │ ├── RefreshKeysDialog
│ │ │ ├── RefreshKeysDialog.test.tsx
│ │ │ ├── RefreshKeysDialog.tsx
│ │ │ ├── useRefreshKeysDialog.test.ts
│ │ │ └── useRefreshKeysDialog.ts
│ │ ├── RemoveNodeDialog.tsx
│ │ ├── ShutdownDialog.tsx
│ │ ├── TracerouteResponseDialog.tsx
│ │ └── UnsafeRolesDialog
│ │ │ ├── UnsafeRolesDialog.test.tsx
│ │ │ ├── UnsafeRolesDialog.tsx
│ │ │ ├── useUnsafeRolesDialog.test.tsx
│ │ │ └── useUnsafeRolesDialog.ts
│ ├── Form
│ │ ├── DynamicForm.test.tsx
│ │ ├── DynamicForm.tsx
│ │ ├── DynamicFormField.tsx
│ │ ├── FormInput.tsx
│ │ ├── FormMultiSelect.tsx
│ │ ├── FormPasswordGenerator.tsx
│ │ ├── FormSelect.tsx
│ │ ├── FormToggle.tsx
│ │ ├── FormWrapper.tsx
│ │ └── createZodResolver.ts
│ ├── KeyBackupReminder.tsx
│ ├── LanguageSwitcher.tsx
│ ├── Map.tsx
│ ├── PageComponents
│ │ ├── Channel.tsx
│ │ ├── Config
│ │ │ ├── Bluetooth.tsx
│ │ │ ├── Device
│ │ │ │ ├── Device.test.tsx
│ │ │ │ └── index.tsx
│ │ │ ├── Display.tsx
│ │ │ ├── LoRa.tsx
│ │ │ ├── Network
│ │ │ │ ├── Network.test.tsx
│ │ │ │ └── index.tsx
│ │ │ ├── Position.tsx
│ │ │ ├── Power.tsx
│ │ │ └── Security
│ │ │ │ └── Security.tsx
│ │ ├── Connect
│ │ │ ├── BLE.tsx
│ │ │ ├── HTTP.test.tsx
│ │ │ ├── HTTP.tsx
│ │ │ └── Serial.tsx
│ │ ├── Map
│ │ │ └── NodeDetail.tsx
│ │ ├── Messages
│ │ │ ├── ChannelChat.tsx
│ │ │ ├── MessageActionsMenu.tsx
│ │ │ ├── MessageInput.test.tsx
│ │ │ ├── MessageInput.tsx
│ │ │ ├── MessageItem.tsx
│ │ │ ├── TraceRoute.test.tsx
│ │ │ └── TraceRoute.tsx
│ │ └── ModuleConfig
│ │ │ ├── AmbientLighting.tsx
│ │ │ ├── Audio.tsx
│ │ │ ├── CannedMessage.tsx
│ │ │ ├── DetectionSensor.tsx
│ │ │ ├── ExternalNotification.tsx
│ │ │ ├── MQTT.tsx
│ │ │ ├── NeighborInfo.tsx
│ │ │ ├── Paxcounter.tsx
│ │ │ ├── RangeTest.tsx
│ │ │ ├── Serial.tsx
│ │ │ ├── StoreForward.tsx
│ │ │ └── Telemetry.tsx
│ ├── PageLayout.tsx
│ ├── Sidebar.tsx
│ ├── ThemeSwitcher.tsx
│ ├── Toaster.tsx
│ ├── UI
│ │ ├── Accordion.tsx
│ │ ├── Avatar.tsx
│ │ ├── Button.tsx
│ │ ├── Checkbox
│ │ │ ├── Checkbox.test.tsx
│ │ │ └── index.tsx
│ │ ├── Command.tsx
│ │ ├── Dialog.tsx
│ │ ├── DropdownMenu.tsx
│ │ ├── ErrorPage.tsx
│ │ ├── Footer.tsx
│ │ ├── Generator.tsx
│ │ ├── Input.tsx
│ │ ├── Label.tsx
│ │ ├── Menubar.tsx
│ │ ├── MultiSelect.tsx
│ │ ├── Popover.tsx
│ │ ├── ScrollArea.tsx
│ │ ├── Select.tsx
│ │ ├── Seperator.tsx
│ │ ├── Sidebar
│ │ │ ├── SidebarButton.tsx
│ │ │ └── SidebarSection.tsx
│ │ ├── Slider.tsx
│ │ ├── Spinner.tsx
│ │ ├── Switch.tsx
│ │ ├── Tabs.tsx
│ │ ├── Toast.tsx
│ │ ├── ToggleGroup.tsx
│ │ ├── Tooltip.tsx
│ │ └── Typography
│ │ │ ├── Blockquote.tsx
│ │ │ ├── Code.tsx
│ │ │ ├── Heading.tsx
│ │ │ ├── Link.tsx
│ │ │ ├── P.tsx
│ │ │ └── Subtle.tsx
│ ├── generic
│ │ ├── Blur.tsx
│ │ ├── DeviceImage.tsx
│ │ ├── Filter
│ │ │ ├── FilterComponents.tsx
│ │ │ ├── FilterControl.tsx
│ │ │ ├── useFilterNode.test.ts
│ │ │ └── useFilterNode.ts
│ │ ├── Mono.tsx
│ │ ├── Table
│ │ │ ├── index.test.tsx
│ │ │ └── index.tsx
│ │ ├── TimeAgo.tsx
│ │ └── Uptime.tsx
│ └── types.ts
├── core
│ ├── dto
│ │ ├── NodeNumToNodeInfoDTO.ts
│ │ └── PacketToMessageDTO.ts
│ ├── hooks
│ │ ├── useBrowserFeatureDetection.ts
│ │ ├── useCookie.ts
│ │ ├── useCopyToClipboard.ts
│ │ ├── useFavoriteNode.test.ts
│ │ ├── useFavoriteNode.ts
│ │ ├── useIgnoreNode.test.ts
│ │ ├── useIgnoreNode.ts
│ │ ├── useKeyBackupReminder.tsx
│ │ ├── useLang.ts
│ │ ├── useLocalStorage.test.ts
│ │ ├── useLocalStorage.ts
│ │ ├── usePasswordVisibilityToggle.test.ts
│ │ ├── usePasswordVisibilityToggle.ts
│ │ ├── usePinnedItems.test.ts
│ │ ├── usePinnedItems.ts
│ │ ├── usePositionFlags.ts
│ │ ├── useTheme.ts
│ │ ├── useToast.test.tsx
│ │ └── useToast.ts
│ ├── stores
│ │ ├── appStore.ts
│ │ ├── deviceStore.ts
│ │ ├── messageStore
│ │ │ ├── index.ts
│ │ │ ├── messageStore.test.ts
│ │ │ └── types.ts
│ │ ├── sidebarStore.tsx
│ │ └── storage
│ │ │ └── indexDB.ts
│ ├── subscriptions.ts
│ └── utils
│ │ ├── bitwise.ts
│ │ ├── cn.ts
│ │ ├── debounce.test.ts
│ │ ├── debounce.ts
│ │ ├── dotPath.test.ts
│ │ ├── dotPath.ts
│ │ ├── eventBus.test.ts
│ │ ├── eventBus.ts
│ │ ├── github.ts
│ │ ├── ip.test.ts
│ │ ├── ip.ts
│ │ ├── randId.test.ts
│ │ ├── randId.ts
│ │ ├── string.ts
│ │ ├── test.tsx
│ │ └── x25519.ts
├── i18n
│ ├── config.ts
│ └── locales
│ │ └── en
│ │ ├── channels.json
│ │ ├── commandPalette.json
│ │ ├── common.json
│ │ ├── dashboard.json
│ │ ├── deviceConfig.json
│ │ ├── dialog.json
│ │ ├── messages.json
│ │ ├── moduleConfig.json
│ │ ├── nodes.json
│ │ └── ui.json
├── index.css
├── index.tsx
├── pages
│ ├── Channels.tsx
│ ├── Config
│ │ ├── DeviceConfig.tsx
│ │ ├── ModuleConfig.tsx
│ │ └── index.tsx
│ ├── Dashboard
│ │ └── index.tsx
│ ├── Map
│ │ └── index.tsx
│ ├── Messages.test.tsx
│ ├── Messages.tsx
│ └── Nodes.tsx
├── routeTree.gen.ts
├── routes.tsx
├── tests
│ └── setupTests.ts
└── validation
│ ├── channel.test.ts
│ ├── channel.ts
│ ├── config
│ ├── bluetooth.ts
│ ├── device.ts
│ ├── display.ts
│ ├── lora.ts
│ ├── network.ts
│ ├── position.ts
│ ├── power.ts
│ ├── security.test.ts
│ └── security.ts
│ ├── moduleConfig
│ ├── ambientLighting.ts
│ ├── audio.ts
│ ├── cannedMessage.ts
│ ├── detectionSensor.ts
│ ├── externalNotification.ts
│ ├── mqtt.ts
│ ├── neighborInfo.ts
│ ├── paxcounter.ts
│ ├── rangeTest.ts
│ ├── serial.ts
│ ├── storeForward.ts
│ └── telemetry.ts
│ ├── pskSchema.test.ts
│ ├── pskSchema.ts
│ └── validate.ts
├── vercel.json
├── vite.config.ts
└── vitest.config.ts
/.github/ISSUE_TEMPLATE/feature.yml:
--------------------------------------------------------------------------------
1 | name: Feature Request
2 | description: Request a new feature
3 | title: "[Feature Request]: "
4 | labels: ["enhancement"]
5 | body:
6 | - type: markdown
7 | attributes:
8 | value: |
9 | Thanks for your request. While we can't guarantee implementation, all requests will be carefully reviewed.
10 | - type: checkboxes
11 | id: prerequisites
12 | attributes:
13 | label: Prerequisites
14 | description: Please confirm the following before submitting your feature request
15 | options:
16 | - label: I have searched existing issues to ensure this feature hasn't already been requested
17 | required: true
18 | - label: I have checked the documentation to verify this feature doesn't already exist
19 | required: true
20 | - type: textarea
21 | id: problem
22 | attributes:
23 | label: Problem Statement
24 | description: What problem are you trying to solve? Describe the challenge or limitation you're facing.
25 | placeholder: I'm frustrated when...
26 | validations:
27 | required: true
28 | - type: textarea
29 | id: solution
30 | attributes:
31 | label: Proposed Solution
32 | description: Describe your idea for solving the problem. What would you like to see implemented?
33 | placeholder: It would be great if...
34 | validations:
35 | required: true
36 | - type: textarea
37 | id: alternatives
38 | attributes:
39 | label: Current Alternatives
40 | description: Are there any workarounds or alternative solutions you're currently using?
41 | placeholder: Currently, I'm working around this by...
42 | validations:
43 | required: false
44 | - type: dropdown
45 | id: importance
46 | attributes:
47 | label: Importance
48 | description: How important is this feature to you?
49 | options:
50 | - Nice to have
51 | - Important
52 | - Critical
53 | validations:
54 | required: true
55 | - type: textarea
56 | id: context
57 | attributes:
58 | label: Additional Context
59 | description: Add any other context, screenshots, mockups, or examples that might help us understand your request better.
60 | validations:
61 | required: false
62 | - type: markdown
63 | attributes:
64 | value: |
65 | Thank you for taking the time to fill out this feature request!
--------------------------------------------------------------------------------
/.github/pull_request_template.md:
--------------------------------------------------------------------------------
1 |
4 |
5 | ## Description
6 |
7 |
10 |
11 | ## Related Issues
12 |
13 |
17 |
18 | ## Changes Made
19 |
20 |
23 |
24 | -
25 | -
26 | -
27 |
28 | ## Testing Done
29 |
30 |
33 |
34 | ## Screenshots (if applicable)
35 |
36 |
39 |
40 | ## Checklist
41 |
42 |
45 |
46 | - [ ] Code follows project style guidelines
47 | - [ ] Documentation has been updated or added
48 | - [ ] Tests have been added or updated
49 | - [ ] All i18n translation labels have bee added
50 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: Push to Main CI
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 |
8 | permissions:
9 | contents: write
10 | packages: write
11 |
12 | jobs:
13 | build-and-package:
14 | runs-on: ubuntu-latest
15 | steps:
16 | - name: Checkout code
17 | uses: actions/checkout@v4
18 |
19 | - name: Setup Deno
20 | uses: denoland/setup-deno@v2
21 | with:
22 | deno-version: v2.x
23 |
24 | - name: Install Dependencies
25 | run: deno install
26 |
27 | - name: Cache Deno dependencies
28 | uses: actions/cache@v4
29 | with:
30 | path: |
31 | ~/.cache/deno
32 | ./deno.lock
33 | key: ${{ runner.os }}-deno-${{ hashFiles('**/deno.lock') }}
34 | restore-keys: |
35 | ${{ runner.os }}-deno-
36 |
37 | - name: Cache Dependencies
38 | run: deno cache src/index.tsx
39 |
40 | - name: Run linter
41 | run: deno task lint
42 |
43 | - name: Check formatter
44 | run: deno task format --check
45 |
46 | - name: Run tests
47 | run: deno task test
48 |
49 | - name: Build Package
50 | run: deno task build
51 |
--------------------------------------------------------------------------------
/.github/workflows/crowdin-download.yml:
--------------------------------------------------------------------------------
1 | name: Crowdin Download Translations Action
2 |
3 | on:
4 | schedule: # Every Sunday at midnight
5 | - cron: '0 0 * * 0'
6 | workflow_dispatch: # Allow manual triggering
7 |
8 | jobs:
9 | synchronize-with-crowdin:
10 | runs-on: ubuntu-latest
11 |
12 | steps:
13 | - name: Checkout
14 | uses: actions/checkout@v4
15 |
16 | - name: Download translations with Crowdin
17 | uses: crowdin/github-action@v2
18 | with:
19 | base_url: 'https://meshtastic.crowdin.com/api/v2'
20 | config: 'crowdin.yml'
21 | upload_sources: false
22 | upload_translations: false
23 | download_translations: true
24 | localization_branch_name: i18n_crowdin_translations
25 | commit_message: 'chore(i18n): New Crowdin Translations by GitHub Action'
26 | create_pull_request: true
27 | pull_request_title: 'chore(i18n): New Crowdin Translations'
28 | pull_request_body: 'New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)'
29 | pull_request_base_branch_name: 'main'
30 | pull_request_labels: 'i18n'
31 | crowdin_branch_name: 'main'
32 | env:
33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
34 | CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
35 | CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
--------------------------------------------------------------------------------
/.github/workflows/crowdin-upload-sources.yml:
--------------------------------------------------------------------------------
1 | name: Crowdin Upload Sources Action
2 |
3 | on:
4 | push:
5 | # Monitor all .json files within the /src/i18n/locales/en/ directory.
6 | # This ensures the workflow triggers if any the English namespace files are modified on the main branch.
7 | paths:
8 | - '/src/i18n/locales/en/**/*.json'
9 | branches: [ main ]
10 | workflow_dispatch: # Allow manual triggering
11 |
12 | jobs:
13 | synchronize-with-crowdin:
14 | runs-on: ubuntu-latest
15 |
16 | steps:
17 | - name: Checkout
18 | uses: actions/checkout@v4
19 |
20 | - name: Upload sources with Crowdin
21 | uses: crowdin/github-action@v2
22 | with:
23 | base_url: 'https://meshtastic.crowdin.com/api/v2'
24 | config: 'crowdin.yml'
25 | upload_sources: true
26 | upload_translations: false
27 | download_translations: false
28 | crowdin_branch_name: 'main'
29 |
30 | env:
31 | CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
32 | CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
--------------------------------------------------------------------------------
/.github/workflows/crowdin-upload-translations.yml:
--------------------------------------------------------------------------------
1 | name: Crowdin Upload Translations Action
2 |
3 | on:
4 | workflow_dispatch: # Allow manual triggering
5 |
6 | jobs:
7 | synchronize-with-crowdin:
8 | runs-on: ubuntu-latest
9 |
10 | steps:
11 | - name: Checkout
12 | uses: actions/checkout@v4
13 |
14 | - name: Upload translations with Crowdin
15 | uses: crowdin/github-action@v2
16 | with:
17 | base_url: "https://meshtastic.crowdin.com/api/v2"
18 | config: "crowdin.yml"
19 | upload_sources: false
20 | upload_translations: true
21 | download_translations: false
22 | crowdin_branch_name: "main"
23 | env:
24 | CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
25 | CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
26 |
--------------------------------------------------------------------------------
/.github/workflows/nightly.yml:
--------------------------------------------------------------------------------
1 | name: 'Nightly Release'
2 |
3 | on:
4 | schedule:
5 | - cron: "0 5 * * *" # Run every day at 5am UTC
6 |
7 | permissions:
8 | contents: write
9 | packages: write
10 |
11 | jobs:
12 | build-and-package:
13 | runs-on: ubuntu-latest
14 | steps:
15 | - name: Checkout
16 | uses: actions/checkout@v4
17 |
18 | - name: Setup Deno
19 | uses: denoland/setup-deno@v2
20 | with:
21 | deno-version: v2.x
22 |
23 | - name: Install Dependencies
24 | run: deno install
25 |
26 | - name: Run tests
27 | run: deno task test
28 |
29 | - name: Build Package
30 | run: deno task build
31 |
32 | - name: Package Output
33 | run: deno task package
34 |
35 | - name: Archive compressed build
36 | uses: actions/upload-artifact@v4
37 | with:
38 | name: build
39 | path: dist/build.tar
40 |
41 | - name: Set up QEMU
42 | uses: docker/setup-qemu-action@v3
43 |
44 | - name: Buildah Build
45 | id: build-container
46 | uses: redhat-actions/buildah-build@v2
47 | with:
48 | containerfiles: |
49 | ./infra/Containerfile
50 | image: ${{github.event.repository.full_name}}
51 | tags: nightly ${{ github.sha }}
52 | oci: true
53 | platforms: linux/amd64, linux/arm64
54 |
55 | - name: Push To Registry
56 | id: push-to-registry
57 | uses: redhat-actions/push-to-registry@v2
58 | with:
59 | image: ${{ steps.build-container.outputs.image }}
60 | tags: ${{ steps.build-container.outputs.tags }}
61 | registry: ghcr.io
62 | username: ${{ github.actor }}
63 | password: ${{ secrets.GITHUB_TOKEN }}
64 |
65 | - name: Print image url
66 | run: echo "Image pushed to ${{ steps.push-to-registry.outputs.registry-paths }}"
67 |
--------------------------------------------------------------------------------
/.github/workflows/pr.yml:
--------------------------------------------------------------------------------
1 | name: Pull Request CI
2 |
3 | on:
4 | pull_request:
5 |
6 | jobs:
7 | build:
8 | runs-on: ubuntu-latest
9 | steps:
10 | - name: Checkout code
11 | uses: actions/checkout@v4
12 |
13 | - name: Setup Deno
14 | uses: denoland/setup-deno@v2
15 | with:
16 | deno-version: v2.x
17 |
18 | - name: Cache Deno dependencies
19 | uses: actions/cache@v4
20 | with:
21 | path: |
22 | ~/.cache/deno
23 | ./deno.lock
24 | key: ${{ runner.os }}-deno-${{ hashFiles('**/deno.lock') }}
25 | restore-keys: |
26 | ${{ runner.os }}-deno-
27 |
28 | - name: Install Dependencies
29 | run: deno install
30 |
31 | - name: Cache Dependencies
32 | run: deno cache src/index.tsx
33 |
34 | - name: Run linter
35 | run: deno task lint
36 |
37 | - name: Check formatter
38 | run: deno task format --check
39 |
40 | - name: Run tests
41 | run: deno task test
42 |
43 | - name: Build Package
44 | run: deno task build
45 |
46 | - name: Compress build
47 | run: deno task package
48 |
49 | - name: Archive compressed build
50 | uses: actions/upload-artifact@v4
51 | with:
52 | name: build
53 | path: dist/build.tar
54 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | release:
5 | types: [released, prereleased]
6 |
7 | permissions:
8 | contents: write
9 | packages: write
10 |
11 | jobs:
12 | build-and-package:
13 | runs-on: ubuntu-latest
14 | steps:
15 | - name: Checkout
16 | uses: actions/checkout@v4
17 |
18 | - name: Setup Deno
19 | uses: denoland/setup-deno@v2
20 | with:
21 | deno-version: v2.x
22 |
23 | - name: Install Dependencies
24 | run: deno install
25 |
26 | - name: Run tests
27 | run: deno task test
28 |
29 | - name: Build Package
30 | run: deno task build
31 |
32 | - name: Package Output
33 | run: deno task package
34 |
35 | - name: Archive compressed build
36 | uses: actions/upload-artifact@v4
37 | with:
38 | name: build
39 | path: dist/build.tar
40 |
41 | - name: Attach build.tar to release
42 | run: |
43 | gh release upload ${{ github.event.release.tag_name }} dist/build.tar
44 | env:
45 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
46 |
47 | - name: Set up QEMU
48 | uses: docker/setup-qemu-action@v3
49 |
50 | - name: Buildah Build
51 | id: build-container
52 | uses: redhat-actions/buildah-build@v2
53 | with:
54 | containerfiles: |
55 | ./infra/Containerfile
56 | image: ${{github.event.repository.full_name}}
57 | tags: latest ${{ github.sha }}
58 | oci: true
59 | platforms: linux/amd64, linux/arm64
60 |
61 | - name: Push To Registry
62 | id: push-to-registry
63 | uses: redhat-actions/push-to-registry@v2
64 | with:
65 | image: ${{ steps.build-container.outputs.image }}
66 | tags: ${{ steps.build-container.outputs.tags }}
67 | registry: ghcr.io
68 | username: ${{ github.actor }}
69 | password: ${{ secrets.GITHUB_TOKEN }}
70 |
71 | - name: Print image url
72 | run: echo "Image pushed to ${{ steps.push-to-registry.outputs.registry-paths }}"
73 |
--------------------------------------------------------------------------------
/.github/workflows/update-stable-from-master.yml:
--------------------------------------------------------------------------------
1 | name: Update Stable Branch from Main on Latest Release
2 |
3 | on:
4 | release:
5 | types: [released]
6 |
7 | permissions:
8 | contents: write
9 |
10 | jobs:
11 | update-stable-branch:
12 | name: Update Stable Branch from Main
13 | runs-on: ubuntu-latest
14 |
15 | steps:
16 | - name: Checkout repository
17 | uses: actions/checkout@v4
18 | with:
19 | fetch-depth: 0
20 | token: ${{ secrets.GITHUB_TOKEN }}
21 |
22 | - name: Configure Git
23 | run: |
24 | git config user.name "GitHub Actions Bot"
25 | git config user.email "github-actions[bot]@users.noreply.github.com"
26 |
27 | - name: Fetch latest main and stable branches
28 | run: |
29 | git fetch origin main:main
30 | git fetch origin stable:stable || echo "Stable branch not found remotely, will create."
31 |
32 | - name: Get latest main commit SHA
33 | id: get_main_sha
34 | run: echo "MAIN_SHA=$(git rev-parse main)" >> $GITHUB_ENV
35 |
36 | - name: Check out stable branch
37 | run: |
38 | if git show-ref --verify --quiet refs/heads/stable; then
39 | git checkout stable
40 | git pull origin stable # Sync with remote stable if it exists
41 | else
42 | echo "Creating local stable branch based on main HEAD."
43 | git checkout -b stable ${{ env.MAIN_SHA }}
44 | fi
45 |
46 | - name: Reset stable branch to latest main
47 | run: git reset --hard ${{ env.MAIN_SHA }}
48 |
49 | - name: Force push stable branch
50 | run: git push origin stable --force
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | dist
2 | node_modules
3 | stats.html
4 | .vercel
5 | .vite
6 | dev-dist
7 | __screenshots__*
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | @jsr:registry=https://npm.jsr.io
2 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": ["bradlc.vscode-tailwindcss", "biomejs.biome"]
3 | }
4 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "deno.enable": true,
3 | "deno.suggest.imports.autoDiscover": true,
4 | "editor.formatOnSave": true,
5 | "editor.defaultFormatter": "denoland.vscode-deno"
6 | }
7 |
--------------------------------------------------------------------------------
/crowdin.yml:
--------------------------------------------------------------------------------
1 | project_id_env: CROWDIN_PROJECT_ID
2 | api_token_env: CROWDIN_PERSONAL_TOKEN
3 | base_path: "."
4 | base_url: "https://meshtastic.crowdin.com/api/v2"
5 |
6 | preserve_hierarchy: true
7 |
8 | files:
9 | - source: "/src/i18n/locales/en/**/*.json"
10 | translation: "/src/i18n/locales/%locale%/%original_file_name%"
11 |
--------------------------------------------------------------------------------
/deno.json:
--------------------------------------------------------------------------------
1 | {
2 | "imports": {
3 | "@app/": "./src/",
4 | "@pages/": "./src/pages/",
5 | "@components/": "./src/components/",
6 | "@core/": "./src/core/",
7 | "@layouts/": "./src/layouts/",
8 | "@std/path": "jsr:@std/path@^1.1.0"
9 | },
10 | "compilerOptions": {
11 | "lib": [
12 | "DOM",
13 | "DOM.Iterable",
14 | "ESNext",
15 | "deno.window",
16 | "deno.ns"
17 | ],
18 | "jsx": "react-jsx",
19 | "strict": true,
20 | "noUnusedLocals": true,
21 | "noUnusedParameters": true,
22 | "noFallthroughCasesInSwitch": true,
23 | "strictNullChecks": true,
24 | "types": [
25 | "vite/client",
26 | "node",
27 | "@types/web-bluetooth",
28 | "@types/w3c-web-serial"
29 | ],
30 | "strictPropertyInitialization": false
31 | },
32 | "fmt": {
33 | "exclude": [
34 | "src/*.gen.ts",
35 | "*.test.ts",
36 | "*.test.tsx"
37 | ]
38 | },
39 | "lint": {
40 | "exclude": [
41 | "src/*.gen.ts",
42 | "*.test.ts",
43 | "*.test.tsx"
44 | ],
45 | "report": "pretty"
46 | },
47 | "unstable": [
48 | "sloppy-imports"
49 | ]
50 | }
51 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
16 |
17 |
21 |
22 | Meshtastic Web
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/infra/.dockerignore:
--------------------------------------------------------------------------------
1 | ../dist/build.tar
2 | ../dist/output
3 |
--------------------------------------------------------------------------------
/infra/Containerfile:
--------------------------------------------------------------------------------
1 | FROM nginx:1.27-alpine
2 |
3 | RUN rm -r /usr/share/nginx/html \
4 | && mkdir -p /usr/share/nginx/html \
5 | && mkdir -p /etc/nginx/conf.d
6 |
7 | WORKDIR /usr/share/nginx/html
8 |
9 | ADD dist .
10 |
11 | COPY ./infra/default.conf /etc/nginx/conf.d/default.conf
12 |
13 | EXPOSE 8080
14 |
15 | CMD ["nginx", "-g", "daemon off;"]
16 |
--------------------------------------------------------------------------------
/infra/default.conf:
--------------------------------------------------------------------------------
1 | server {
2 | listen 8080;
3 | server_name localhost;
4 |
5 | root /usr/share/nginx/html;
6 | index index.html index.htm;
7 |
8 | location / {
9 | try_files $uri $uri/ =404;
10 | }
11 |
12 | error_page 500 502 503 504 /50x.html;
13 | location = /50x.html {
14 | internal;
15 | }
16 |
17 | location ~ /\.ht {
18 | deny all;
19 | }
20 |
21 | gzip on;
22 | gzip_disable "msie6";
23 |
24 | gzip_vary on;
25 | gzip_proxied any;
26 | gzip_comp_level 6;
27 | gzip_buffers 16 8k;
28 | gzip_http_version 1.1;
29 | gzip_types
30 | text/plain
31 | text/css
32 | text/xml
33 | text/javascript
34 | application/javascript
35 | application/x-javascript
36 | application/json
37 | application/xml
38 | application/xml+rss
39 | font/ttf
40 | font/otf
41 | image/svg+xml;
42 | }
43 |
--------------------------------------------------------------------------------
/postcss.config.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | '@tailwindcss/postcss': {},
4 | },
5 | };
6 |
--------------------------------------------------------------------------------
/public/Logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/public/Logo_Black.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
13 |
--------------------------------------------------------------------------------
/public/Logo_White.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
13 |
--------------------------------------------------------------------------------
/public/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/meshtastic/web/828e5d0903d9bc214c54eceeb0407a9855fb1bb5/public/apple-touch-icon.png
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/meshtastic/web/828e5d0903d9bc214c54eceeb0407a9855fb1bb5/public/favicon.ico
--------------------------------------------------------------------------------
/public/icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | User-agent: *
2 | Allow: /
--------------------------------------------------------------------------------
/public/site.webmanifest:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Meshtastic",
3 | "short_name": "Meshtastic",
4 | "start_url": ".",
5 | "description": "Meshtastic web app",
6 | "icons": [
7 | {
8 | "src": "/icon.svg",
9 | "sizes": "any",
10 | "type": "image/svg+xml"
11 | }
12 | ],
13 | "theme_color": "#67ea94",
14 | "background_color": "#67ea94",
15 | "display": "standalone"
16 | }
17 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { DeviceWrapper } from "@app/DeviceWrapper.tsx";
2 | import { DialogManager } from "@components/Dialog/DialogManager.tsx";
3 | import { NewDeviceDialog } from "@components/Dialog/NewDeviceDialog.tsx";
4 | import { KeyBackupReminder } from "@components/KeyBackupReminder.tsx";
5 | import { Toaster } from "@components/Toaster.tsx";
6 | import Footer from "@components/UI/Footer.tsx";
7 | import { useAppStore } from "@core/stores/appStore.ts";
8 | import { useDeviceStore } from "@core/stores/deviceStore.ts";
9 | import { Dashboard } from "@pages/Dashboard/index.tsx";
10 | import { ErrorBoundary } from "react-error-boundary";
11 | import { ErrorPage } from "@components/UI/ErrorPage.tsx";
12 | import { MapProvider } from "react-map-gl/maplibre";
13 | import { CommandPalette } from "@components/CommandPalette/index.tsx";
14 | import { SidebarProvider } from "@core/stores/sidebarStore.tsx";
15 | import { useTheme } from "@core/hooks/useTheme.ts";
16 | import { Outlet } from "@tanstack/react-router";
17 | import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
18 |
19 | export function App() {
20 | const { getDevice } = useDeviceStore();
21 | const { selectedDevice, setConnectDialogOpen, connectDialogOpen } =
22 | useAppStore();
23 |
24 | const device = getDevice(selectedDevice);
25 |
26 | // Sets up light/dark mode based on user preferences or system settings
27 | useTheme();
28 |
29 | return (
30 |
31 | {
34 | setConnectDialogOpen(open);
35 | }}
36 | />
37 |
38 |
39 |
40 |
44 |
45 |
46 | {device
47 | ? (
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 | )
57 | : (
58 | <>
59 |
60 |
61 | >
62 | )}
63 |
64 |
65 |
66 |
67 |
68 | );
69 | }
70 |
--------------------------------------------------------------------------------
/src/DeviceWrapper.tsx:
--------------------------------------------------------------------------------
1 | import { DeviceContext } from "@core/stores/deviceStore.ts";
2 | import type { Device } from "@core/stores/deviceStore.ts";
3 | import type { ReactNode } from "react";
4 |
5 | export interface DeviceWrapperProps {
6 | children: ReactNode;
7 | device?: Device;
8 | }
9 |
10 | export const DeviceWrapper = ({ children, device }: DeviceWrapperProps) => {
11 | return (
12 | {children}
13 | );
14 | };
15 |
--------------------------------------------------------------------------------
/src/__mocks__/README.md:
--------------------------------------------------------------------------------
1 | # Mocks Directory
2 |
3 | This directory contains mock implementations used by Vitest for testing.
4 |
5 | ## Structure
6 |
7 | The directory structure mirrors the actual project structure to make mocking
8 | more intuitive:
9 |
10 | ```
11 | __mocks__/
12 | ├── components/
13 | │ └── UI/
14 | │ ├── Dialog.tsx
15 | │ ├── Button.tsx
16 | │ ├── Checkbox.tsx
17 | │ └── ...
18 | ├── core/
19 | │ └── ...
20 | └── ...
21 | ```
22 |
23 | ## Auto-mocking
24 |
25 | Vitest will automatically use the mock files in this directory when the
26 | corresponding module is imported in tests. For example, when a test imports
27 | `@components/UI/Dialog.tsx`, Vitest will use
28 | `__mocks__/components/UI/Dialog.tsx` instead.
29 |
30 | ## Creating New Mocks
31 |
32 | To create a new mock:
33 |
34 | 1. Create a file in the same relative path as the original module
35 | 2. Export the mocked functionality with the same names as the original
36 | 3. Add a `vi.mock()` statement to `vitest.setup.ts` if needed
37 |
38 | ## Mock Guidelines
39 |
40 | - Keep mocks as simple as possible
41 | - Use `data-testid` attributes for easy querying in tests
42 | - Implement just enough functionality to test the component
43 | - Use TypeScript types to ensure compatibility with the original module
44 |
--------------------------------------------------------------------------------
/src/__mocks__/components/UI/Button.tsx:
--------------------------------------------------------------------------------
1 | import { vi } from "vitest";
2 |
3 | vi.mock("@components/UI/Button.tsx", () => ({
4 | Button: ({ children, name, disabled, onClick }: {
5 | children: React.ReactNode;
6 | variant: string;
7 | name: string;
8 | disabled?: boolean;
9 | onClick: () => void;
10 | }) => (
11 |
20 | ),
21 | }));
22 |
--------------------------------------------------------------------------------
/src/__mocks__/components/UI/Checkbox.tsx:
--------------------------------------------------------------------------------
1 | import { vi } from "vitest";
2 |
3 | vi.mock("@components/UI/Checkbox.tsx", () => ({
4 | Checkbox: (
5 | { id, checked, onChange }: {
6 | id: string;
7 | checked: boolean;
8 | onChange: () => void;
9 | },
10 | ) => (
11 |
18 | ),
19 | }));
20 |
--------------------------------------------------------------------------------
/src/__mocks__/components/UI/Dialog/Dialog.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | export const Dialog = ({ children, open }: {
4 | children: React.ReactNode;
5 | open: boolean;
6 | onOpenChange?: (open: boolean) => void;
7 | }) => open ? {children}
: null;
8 |
9 | export const DialogContent = ({
10 | children,
11 | className,
12 | }: {
13 | children: React.ReactNode;
14 | className?: string;
15 | }) => {children}
;
16 |
17 | export const DialogHeader = ({
18 | children,
19 | }: {
20 | children: React.ReactNode;
21 | }) => {children}
;
22 |
23 | export const DialogTitle = ({
24 | children,
25 | }: {
26 | children: React.ReactNode;
27 | }) => {children}
;
28 |
29 | export const DialogDescription = ({
30 | children,
31 | className,
32 | }: {
33 | children: React.ReactNode;
34 | className?: string;
35 | }) => (
36 | {children}
37 | );
38 |
39 | export const DialogFooter = ({
40 | children,
41 | className,
42 | }: {
43 | children: React.ReactNode;
44 | className?: string;
45 | }) => {children}
;
46 |
--------------------------------------------------------------------------------
/src/__mocks__/components/UI/Label.tsx:
--------------------------------------------------------------------------------
1 | import { vi } from "vitest";
2 |
3 | vi.mock("@components/UI/Label.tsx", () => ({
4 | Label: (
5 | { children, htmlFor, className }: {
6 | children: React.ReactNode;
7 | htmlFor: string;
8 | className?: string;
9 | },
10 | ) => (
11 |
14 | ),
15 | }));
16 |
--------------------------------------------------------------------------------
/src/__mocks__/components/UI/Link.tsx:
--------------------------------------------------------------------------------
1 | import { vi } from "vitest";
2 |
3 | vi.mock("@components/UI/Typography/Link.tsx", () => ({
4 | Link: (
5 | { children, href, className }: {
6 | children: React.ReactNode;
7 | href: string;
8 | className?: string;
9 | },
10 | ) => {children},
11 | }));
12 |
--------------------------------------------------------------------------------
/src/components/BatteryStatus.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import {
3 | BatteryFullIcon,
4 | BatteryLowIcon,
5 | BatteryMediumIcon,
6 | PlugZapIcon,
7 | } from "lucide-react";
8 | import { useTranslation } from "react-i18next";
9 | import { DeviceMetrics } from "./types.ts";
10 |
11 | interface BatteryStateConfig {
12 | condition: (level: number) => boolean;
13 | Icon: React.ElementType;
14 | className: string;
15 | text: (level: number) => string;
16 | }
17 |
18 | interface BatteryStatusProps {
19 | deviceMetrics?: DeviceMetrics | null;
20 | }
21 |
22 | const getBatteryStates = (
23 | t: (key: string, options?: object) => string,
24 | ): BatteryStateConfig[] => {
25 | return [
26 | {
27 | condition: (level) => level > 100,
28 | Icon: PlugZapIcon,
29 | className: "text-gray-500",
30 | text: () => t("batteryStatus.pluggedIn"),
31 | },
32 | {
33 | condition: (level) => level > 80,
34 | Icon: BatteryFullIcon,
35 | className: "text-green-500",
36 | text: (level) => t("batteryStatus.charging", { level }),
37 | },
38 | {
39 | condition: (level) => level > 20,
40 | Icon: BatteryMediumIcon,
41 | className: "text-yellow-500",
42 | text: (level) => t("batteryStatus.charging", { level }),
43 | },
44 | {
45 | condition: () => true,
46 | Icon: BatteryLowIcon,
47 | className: "text-red-500",
48 | text: (level) => t("batteryStatus.charging", { level }),
49 | },
50 | ];
51 | };
52 |
53 | const getBatteryState = (
54 | level: number,
55 | batteryStates: BatteryStateConfig[],
56 | ) => {
57 | return batteryStates.find((state) => state.condition(level));
58 | };
59 |
60 | const BatteryStatus: React.FC = ({ deviceMetrics }) => {
61 | if (
62 | deviceMetrics?.batteryLevel === undefined ||
63 | deviceMetrics?.batteryLevel === null
64 | ) {
65 | return null;
66 | }
67 |
68 | const { t } = useTranslation();
69 | const batteryStates = getBatteryStates(t);
70 |
71 | const { batteryLevel } = deviceMetrics;
72 | const currentState = getBatteryState(batteryLevel, batteryStates) ??
73 | batteryStates[batteryStates.length - 1];
74 |
75 | const BatteryIcon = currentState.Icon;
76 | const iconClassName = currentState.className;
77 | const statusText = currentState.text(batteryLevel);
78 |
79 | return (
80 |
84 |
85 | {statusText}
86 |
87 | );
88 | };
89 |
90 | export default BatteryStatus;
91 |
--------------------------------------------------------------------------------
/src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@components/UI/Button.tsx";
2 | import {
3 | Dialog,
4 | DialogClose,
5 | DialogContent,
6 | DialogDescription,
7 | DialogFooter,
8 | DialogHeader,
9 | DialogTitle,
10 | } from "@components/UI/Dialog.tsx";
11 | import { AlertTriangleIcon } from "lucide-react";
12 | import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
13 | import { useTranslation } from "react-i18next";
14 |
15 | export interface DeleteMessagesDialogProps {
16 | open: boolean;
17 | onOpenChange: (open: boolean) => void;
18 | }
19 |
20 | export const DeleteMessagesDialog = ({
21 | open,
22 | onOpenChange,
23 | }: DeleteMessagesDialogProps) => {
24 | const { t } = useTranslation("dialog");
25 | const { deleteAllMessages } = useMessageStore();
26 | const handleCloseDialog = () => {
27 | onOpenChange(false);
28 | };
29 |
30 | return (
31 |
64 | );
65 | };
66 |
--------------------------------------------------------------------------------
/src/components/Dialog/LocationResponseDialog.tsx:
--------------------------------------------------------------------------------
1 | import { useDevice } from "@core/stores/deviceStore.ts";
2 | import {
3 | Dialog,
4 | DialogClose,
5 | DialogContent,
6 | DialogDescription,
7 | DialogHeader,
8 | DialogTitle,
9 | } from "../UI/Dialog.tsx";
10 | import type { Protobuf, Types } from "@meshtastic/core";
11 | import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
12 | import { useTranslation } from "react-i18next";
13 |
14 | export interface LocationResponseDialogProps {
15 | location: Types.PacketMetadata | undefined;
16 | open: boolean;
17 | onOpenChange: () => void;
18 | }
19 |
20 | export const LocationResponseDialog = ({
21 | location,
22 | open,
23 | onOpenChange,
24 | }: LocationResponseDialogProps) => {
25 | const { t } = useTranslation("dialog");
26 | const { getNode } = useDevice();
27 |
28 | const from = getNode(location?.from ?? 0);
29 | const longName = from?.user?.longName ??
30 | (from ? `!${numberToHexUnpadded(from?.num)}` : t("unknown.shortName"));
31 | const shortName = from?.user?.shortName ??
32 | (from
33 | ? `${numberToHexUnpadded(from?.num).substring(0, 4)}`
34 | : t("unknown.shortName"));
35 |
36 | return (
37 |
76 | );
77 | };
78 |
--------------------------------------------------------------------------------
/src/components/Dialog/PkiRegenerateDialog.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@components/UI/Button.tsx";
2 | import {
3 | Dialog,
4 | DialogClose,
5 | DialogContent,
6 | DialogDescription,
7 | DialogFooter,
8 | DialogHeader,
9 | DialogTitle,
10 | } from "@components/UI/Dialog.tsx";
11 | import { useTranslation } from "react-i18next";
12 |
13 | export interface PkiRegenerateDialogProps {
14 | text: {
15 | title: string;
16 | description: string;
17 | button: string;
18 | };
19 | open: boolean;
20 | onOpenChange: () => void;
21 | onSubmit: () => void;
22 | }
23 |
24 | export const PkiRegenerateDialog = ({
25 | text = {
26 | title: "",
27 | description: "",
28 | button: "",
29 | },
30 | open,
31 | onOpenChange,
32 | onSubmit,
33 | }: PkiRegenerateDialogProps) => {
34 | const { t } = useTranslation("dialog");
35 | const dialogText = {
36 | title: text.title || t("pkiRegenerate.title"),
37 | description: text.description ||
38 | t("pkiRegenerate.description"),
39 | button: text.button || t("button.regenerate"),
40 | };
41 | return (
42 |
62 | );
63 | };
64 |
--------------------------------------------------------------------------------
/src/components/Dialog/RebootDialog.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@components/UI/Button.tsx";
2 | import {
3 | Dialog,
4 | DialogClose,
5 | DialogContent,
6 | DialogDescription,
7 | DialogHeader,
8 | DialogTitle,
9 | } from "@components/UI/Dialog.tsx";
10 | import { Input } from "@components/UI/Input.tsx";
11 | import { useDevice } from "@core/stores/deviceStore.ts";
12 | import { ClockIcon, RefreshCwIcon } from "lucide-react";
13 | import { useTranslation } from "react-i18next";
14 | import { useState } from "react";
15 |
16 | export interface RebootDialogProps {
17 | open: boolean;
18 | onOpenChange: (open: boolean) => void;
19 | }
20 |
21 | export const RebootDialog = ({
22 | open,
23 | onOpenChange,
24 | }: RebootDialogProps) => {
25 | const { t } = useTranslation("dialog");
26 | const { connection } = useDevice();
27 |
28 | const [time, setTime] = useState(5);
29 |
30 | return (
31 |
68 | );
69 | };
70 |
--------------------------------------------------------------------------------
/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.test.tsx:
--------------------------------------------------------------------------------
1 | import { render } from "@testing-library/react";
2 | import { DeviceContext, useDeviceStore } from "@core/stores/deviceStore.ts";
3 | import { RefreshKeysDialog } from "./RefreshKeysDialog.tsx";
4 | import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
5 | import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
6 | import { afterEach, beforeEach, expect, test, vi } from "vitest";
7 |
8 | vi.mock("@core/stores/messageStore");
9 | vi.mock("./useRefreshKeysDialog");
10 |
11 | const mockUseMessageStore = vi.mocked(useMessageStore);
12 | const mockUseRefreshKeysDialog = vi.mocked(useRefreshKeysDialog);
13 |
14 | const getInitialState = () =>
15 | useDeviceStore.getInitialState?.() ??
16 | { devices: new Map(), remoteDevices: new Map() };
17 |
18 | beforeEach(() => {
19 | useDeviceStore.setState(getInitialState(), true);
20 | vi.clearAllMocks();
21 | });
22 |
23 | afterEach(() => {
24 | vi.restoreAllMocks();
25 | });
26 |
27 | test("does not render dialog if no error exists for active chat", () => {
28 | const deviceId = 1;
29 | const activeChatNum = 54321;
30 |
31 | useDeviceStore.getState().addDevice(deviceId);
32 |
33 | const currentDeviceState = useDeviceStore.getState().getDevice(deviceId);
34 | if (!currentDeviceState) throw new Error("Device not found");
35 |
36 | mockUseMessageStore.mockReturnValue({ activeChat: activeChatNum });
37 | mockUseRefreshKeysDialog.mockReturnValue({
38 | handleCloseDialog: vi.fn(),
39 | handleNodeRemove: vi.fn(),
40 | });
41 |
42 | const { container } = render(
43 |
44 |
45 | ,
46 | );
47 |
48 | expect(container.firstChild).toBeNull();
49 | });
50 |
--------------------------------------------------------------------------------
/src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.ts:
--------------------------------------------------------------------------------
1 | import { useCallback } from "react";
2 | import { useDevice } from "@core/stores/deviceStore.ts";
3 | import { useMessageStore } from "@core/stores/messageStore/index.ts";
4 |
5 | export function useRefreshKeysDialog() {
6 | const { removeNode, setDialogOpen, clearNodeError, getNodeError } =
7 | useDevice();
8 | const { activeChat } = useMessageStore();
9 |
10 | const handleCloseDialog = useCallback(() => {
11 | setDialogOpen("refreshKeys", false);
12 | }, [setDialogOpen]);
13 |
14 | const handleNodeRemove = useCallback(() => {
15 | const nodeWithError = getNodeError(activeChat);
16 | if (!nodeWithError) {
17 | return;
18 | }
19 | clearNodeError(activeChat);
20 | handleCloseDialog();
21 | return removeNode(nodeWithError?.node);
22 | }, [activeChat, clearNodeError, getNodeError, removeNode, handleCloseDialog]);
23 |
24 | return {
25 | handleCloseDialog,
26 | handleNodeRemove,
27 | };
28 | }
29 |
--------------------------------------------------------------------------------
/src/components/Dialog/RemoveNodeDialog.tsx:
--------------------------------------------------------------------------------
1 | import { useAppStore } from "../../core/stores/appStore.ts";
2 | import { useDevice } from "@core/stores/deviceStore.ts";
3 | import { Button } from "@components/UI/Button.tsx";
4 | import {
5 | Dialog,
6 | DialogClose,
7 | DialogContent,
8 | DialogDescription,
9 | DialogFooter,
10 | DialogHeader,
11 | DialogTitle,
12 | } from "@components/UI/Dialog.tsx";
13 | import { Label } from "@components/UI/Label.tsx";
14 | import { useTranslation } from "react-i18next";
15 |
16 | export interface RemoveNodeDialogProps {
17 | open: boolean;
18 | onOpenChange: (open: boolean) => void;
19 | }
20 |
21 | export const RemoveNodeDialog = ({
22 | open,
23 | onOpenChange,
24 | }: RemoveNodeDialogProps) => {
25 | const { t } = useTranslation("dialog");
26 | const { connection, getNode, removeNode } = useDevice();
27 | const { nodeNumToBeRemoved } = useAppStore();
28 |
29 | const onSubmit = () => {
30 | connection?.removeNodeByNum(nodeNumToBeRemoved);
31 | removeNode(nodeNumToBeRemoved);
32 | onOpenChange(false);
33 | };
34 |
35 | return (
36 |
61 | );
62 | };
63 |
--------------------------------------------------------------------------------
/src/components/Dialog/ShutdownDialog.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@components/UI/Button.tsx";
2 | import {
3 | Dialog,
4 | DialogClose,
5 | DialogContent,
6 | DialogDescription,
7 | DialogHeader,
8 | DialogTitle,
9 | } from "@components/UI/Dialog.tsx";
10 | import { Input } from "@components/UI/Input.tsx";
11 | import { useDevice } from "@core/stores/deviceStore.ts";
12 | import { ClockIcon, PowerIcon } from "lucide-react";
13 | import { useTranslation } from "react-i18next";
14 | import { useState } from "react";
15 |
16 | export interface ShutdownDialogProps {
17 | open: boolean;
18 | onOpenChange: (open: boolean) => void;
19 | }
20 |
21 | export const ShutdownDialog = ({
22 | open,
23 | onOpenChange,
24 | }: ShutdownDialogProps) => {
25 | const { t } = useTranslation("dialog");
26 | const { connection } = useDevice();
27 |
28 | const [time, setTime] = useState(5);
29 |
30 | return (
31 |
71 | );
72 | };
73 |
--------------------------------------------------------------------------------
/src/components/Dialog/TracerouteResponseDialog.tsx:
--------------------------------------------------------------------------------
1 | import { useDevice } from "../../core/stores/deviceStore.ts";
2 | import {
3 | Dialog,
4 | DialogClose,
5 | DialogContent,
6 | DialogDescription,
7 | DialogHeader,
8 | DialogTitle,
9 | } from "../UI/Dialog.tsx";
10 | import type { Protobuf, Types } from "@meshtastic/core";
11 | import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
12 |
13 | import { TraceRoute } from "../PageComponents/Messages/TraceRoute.tsx";
14 | import { useTranslation } from "react-i18next";
15 |
16 | export interface TracerouteResponseDialogProps {
17 | traceroute: Types.PacketMetadata | undefined;
18 | open: boolean;
19 | onOpenChange: () => void;
20 | }
21 |
22 | export const TracerouteResponseDialog = ({
23 | traceroute,
24 | open,
25 | onOpenChange,
26 | }: TracerouteResponseDialogProps) => {
27 | const { t } = useTranslation("dialog");
28 | const { getNode } = useDevice();
29 | const route: number[] = traceroute?.data.route ?? [];
30 | const routeBack: number[] = traceroute?.data.routeBack ?? [];
31 | const snrTowards = (traceroute?.data.snrTowards ?? []).map((snr) => snr / 4);
32 | const snrBack = (traceroute?.data.snrBack ?? []).map((snr) => snr / 4);
33 | const from = getNode(traceroute?.from ?? 0);
34 | const longName = from?.user?.longName ??
35 | (from ? `!${numberToHexUnpadded(from?.num)}` : t("unknown.shortName"));
36 | const shortName = from?.user?.shortName ??
37 | (from
38 | ? `${numberToHexUnpadded(from?.num).substring(0, 4)}`
39 | : t("unknown.shortName"));
40 | const to = getNode(traceroute?.to ?? 0);
41 | return (
42 |
64 | );
65 | };
66 |
--------------------------------------------------------------------------------
/src/components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts:
--------------------------------------------------------------------------------
1 | import { useCallback } from "react";
2 | import { eventBus } from "@core/utils/eventBus.ts";
3 | import { useDevice } from "@core/stores/deviceStore.ts";
4 |
5 | export const UNSAFE_ROLES = ["ROUTER", "ROUTER_LATE", "REPEATER"];
6 | export type UnsafeRole = typeof UNSAFE_ROLES[number];
7 |
8 | export const useUnsafeRolesDialog = () => {
9 | const { setDialogOpen } = useDevice();
10 |
11 | const handleCloseDialog = useCallback(() => {
12 | setDialogOpen("unsafeRoles", false);
13 | }, [setDialogOpen]);
14 |
15 | const validateRoleSelection = useCallback(
16 | (newRoleKey: string): Promise => {
17 | if (!UNSAFE_ROLES.includes(newRoleKey as UnsafeRole)) {
18 | return Promise.resolve(true);
19 | }
20 |
21 | setDialogOpen("unsafeRoles", true);
22 |
23 | return new Promise((resolve) => {
24 | const handleResponse = (
25 | { action }: { action: "confirm" | "dismiss" },
26 | ) => {
27 | eventBus.off("dialog:unsafeRoles", handleResponse);
28 | resolve(action === "confirm");
29 | };
30 |
31 | eventBus.on("dialog:unsafeRoles", handleResponse);
32 | });
33 | },
34 | [setDialogOpen],
35 | );
36 |
37 | return {
38 | handleCloseDialog,
39 | validateRoleSelection,
40 | };
41 | };
42 |
--------------------------------------------------------------------------------
/src/components/Form/DynamicFormField.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | type MultiSelectFieldProps,
3 | MultiSelectInput,
4 | } from "./FormMultiSelect.tsx";
5 | import {
6 | GenericInput,
7 | type InputFieldProps,
8 | } from "@components/Form/FormInput.tsx";
9 | import {
10 | PasswordGenerator,
11 | type PasswordGeneratorProps,
12 | } from "@components/Form/FormPasswordGenerator.tsx";
13 | import {
14 | type SelectFieldProps,
15 | SelectInput,
16 | } from "@components/Form/FormSelect.tsx";
17 | import {
18 | type ToggleFieldProps,
19 | ToggleInput,
20 | } from "@components/Form/FormToggle.tsx";
21 | import type { Control, FieldValues } from "react-hook-form";
22 |
23 | export type FieldProps =
24 | | InputFieldProps
25 | | SelectFieldProps
26 | | MultiSelectFieldProps
27 | | ToggleFieldProps
28 | | PasswordGeneratorProps;
29 |
30 | export interface DynamicFormFieldProps {
31 | field: FieldProps;
32 | control: Control;
33 | disabled?: boolean;
34 | }
35 |
36 | export function DynamicFormField({
37 | field,
38 | control,
39 | disabled,
40 | }: DynamicFormFieldProps) {
41 | switch (field.type) {
42 | case "text":
43 | case "password":
44 | case "number":
45 | return (
46 |
47 | );
48 |
49 | case "toggle":
50 | return (
51 |
56 | );
57 | case "select":
58 | return (
59 |
64 | );
65 | case "passwordGenerator":
66 | return (
67 |
72 | );
73 | case "multiSelect":
74 | return (
75 |
76 | );
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/src/components/Form/FormMultiSelect.tsx:
--------------------------------------------------------------------------------
1 | import type {
2 | BaseFormBuilderProps,
3 | GenericFormElementProps,
4 | } from "@components/Form/DynamicForm.tsx";
5 | import type { FieldValues } from "react-hook-form";
6 | import { useTranslation } from "react-i18next";
7 | import type { FLAGS_CONFIG } from "@core/hooks/usePositionFlags.ts";
8 | import { MultiSelect, MultiSelectItem } from "../UI/MultiSelect.tsx";
9 |
10 | export interface MultiSelectFieldProps extends BaseFormBuilderProps {
11 | type: "multiSelect";
12 | placeholder?: string;
13 | onValueChange: (name: string) => void;
14 | isChecked: (name: string) => boolean;
15 | value: string[];
16 | properties: BaseFormBuilderProps["properties"] & {
17 | enumValue:
18 | | { [s: string]: string | number }
19 | | typeof FLAGS_CONFIG;
20 | formatEnumName?: boolean;
21 | };
22 | }
23 |
24 | export function MultiSelectInput({
25 | field,
26 | }: GenericFormElementProps>) {
27 | const { t } = useTranslation("deviceConfig");
28 | const { enumValue, ...remainingProperties } = field.properties;
29 |
30 | const isNewConfigStructure =
31 | typeof Object.values(enumValue)[0] === "object" &&
32 | Object.values(enumValue)[0] !== null &&
33 | "i18nKey" in Object.values(enumValue)[0];
34 |
35 | const optionsToRender = Object.entries(enumValue).map(
36 | ([key, configOrValue]) => {
37 | if (isNewConfigStructure) {
38 | const config =
39 | configOrValue as typeof FLAGS_CONFIG[keyof typeof FLAGS_CONFIG];
40 | return {
41 | key,
42 | display: t(config.i18nKey),
43 | value: config.value,
44 | };
45 | }
46 | return { key, display: key, value: configOrValue as number };
47 | },
48 | );
49 |
50 | return (
51 |
52 | {optionsToRender.map((option) => {
53 | return (
54 | field.onValueChange(option.key)}
60 | >
61 | {option.display}
62 |
63 | );
64 | })}
65 |
66 | );
67 | }
68 |
--------------------------------------------------------------------------------
/src/components/Form/FormPasswordGenerator.tsx:
--------------------------------------------------------------------------------
1 | import type {
2 | BaseFormBuilderProps,
3 | GenericFormElementProps,
4 | } from "@components/Form/DynamicForm.tsx";
5 | import type { ButtonVariant } from "../UI/Button.tsx";
6 | import { Generator } from "@components/UI/Generator.tsx";
7 | import { Controller, type FieldValues, useFormContext } from "react-hook-form";
8 | import { usePasswordVisibilityToggle } from "@core/hooks/usePasswordVisibilityToggle.ts";
9 | import { useEffect } from "react";
10 |
11 | export interface PasswordGeneratorProps extends BaseFormBuilderProps {
12 | type: "passwordGenerator";
13 | id: string;
14 | hide?: boolean;
15 | bits?: { text: string; value: string; key: string }[];
16 | devicePSKBitCount: number;
17 | inputChange?: React.ChangeEventHandler;
18 | selectChange?: (event: string) => void;
19 | actionButtons: {
20 | text: string;
21 | onClick: React.MouseEventHandler;
22 | variant: ButtonVariant;
23 | className?: string;
24 | }[];
25 | showPasswordToggle?: boolean;
26 | showCopyButton?: boolean;
27 | }
28 |
29 | export function PasswordGenerator({
30 | control,
31 | field,
32 | disabled,
33 | }: GenericFormElementProps>) {
34 | const { isVisible } = usePasswordVisibilityToggle();
35 | const { trigger } = useFormContext();
36 |
37 | useEffect(() => {
38 | trigger(field.name);
39 | }, [field.devicePSKBitCount, field.name, trigger]);
40 |
41 | return (
42 | (
46 | {
52 | if (field.inputChange) field.inputChange(e);
53 | onChange(e);
54 | }}
55 | selectChange={field.selectChange ?? (() => {})}
56 | value={value}
57 | variant={field.validationText ? "invalid" : "default"}
58 | actionButtons={field.actionButtons}
59 | showPasswordToggle={field.showPasswordToggle}
60 | showCopyButton={field.showCopyButton}
61 | {...field.properties}
62 | {...rest}
63 | disabled={disabled}
64 | />
65 | )}
66 | />
67 | );
68 | }
69 |
--------------------------------------------------------------------------------
/src/components/Form/FormToggle.tsx:
--------------------------------------------------------------------------------
1 | import type {
2 | BaseFormBuilderProps,
3 | GenericFormElementProps,
4 | } from "@components/Form/DynamicForm.tsx";
5 | import { Switch } from "@components/UI/Switch.tsx";
6 | import { Controller, type FieldValues } from "react-hook-form";
7 |
8 | export interface ToggleFieldProps extends BaseFormBuilderProps {
9 | type: "toggle";
10 | inputChange?: (value: boolean) => void;
11 | }
12 |
13 | export function ToggleInput({
14 | control,
15 | disabled,
16 | field,
17 | }: GenericFormElementProps>) {
18 | return (
19 | (
23 | {
26 | onChange(v);
27 | field.inputChange?.(v);
28 | }}
29 | id={field.name}
30 | disabled={disabled}
31 | {...field.properties}
32 | {...rest}
33 | />
34 | )}
35 | />
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/src/components/Form/FormWrapper.tsx:
--------------------------------------------------------------------------------
1 | import { Label } from "@components/UI/Label.tsx";
2 |
3 | export interface FieldWrapperProps {
4 | label: string;
5 | fieldName: string;
6 | description?: string;
7 | disabled?: boolean;
8 | children?: React.ReactNode;
9 | valid?: boolean;
10 | validationText?: string;
11 | }
12 |
13 | export const FieldWrapper = ({
14 | label,
15 | fieldName,
16 | description,
17 | children,
18 | valid,
19 | validationText,
20 | }: FieldWrapperProps) => (
21 |
22 |
39 |
40 | );
41 |
--------------------------------------------------------------------------------
/src/components/Form/createZodResolver.ts:
--------------------------------------------------------------------------------
1 | import type { ZodType } from "zod/v4";
2 | import type {
3 | FieldError,
4 | FieldValues,
5 | Resolver,
6 | ResolverOptions,
7 | ResolverResult,
8 | } from "react-hook-form";
9 |
10 | export function createZodResolver(
11 | schema: ZodType,
12 | ): Resolver {
13 | return (
14 | values: T,
15 | _context: unknown,
16 | _options?: ResolverOptions,
17 | ): ResolverResult => {
18 | const result = schema.safeParse(values);
19 | if (result.success) {
20 | return {
21 | values: result.data,
22 | errors: {},
23 | };
24 | }
25 |
26 | const errors: Record<
27 | string,
28 | FieldError & { params?: Record }
29 | > = {};
30 |
31 | for (const issue of result.error.issues) {
32 | const { path, code, message, ...params } = issue;
33 | const key = path.join(".");
34 |
35 | const suffix = "format" in params
36 | ? params.format
37 | : "origin" in params
38 | ? params.origin
39 | : "expected" in params
40 | ? params.expected
41 | : "";
42 |
43 | const newCode = code.replace(
44 | /_([a-z])/g,
45 | (_, char) => char.toUpperCase(),
46 | ) + (suffix ? `.${suffix}` : "");
47 |
48 | const fieldError: FieldError & { params?: Record } = {
49 | type: newCode,
50 | message: message,
51 | ...(Object.keys(params).length ? { params } : {}),
52 | };
53 |
54 | if (!errors[key]) {
55 | errors[key] = fieldError;
56 | }
57 | }
58 |
59 | return {
60 | values: {} as T,
61 | errors,
62 | };
63 | };
64 | }
65 |
--------------------------------------------------------------------------------
/src/components/KeyBackupReminder.tsx:
--------------------------------------------------------------------------------
1 | import { useBackupReminder } from "@core/hooks/useKeyBackupReminder.tsx";
2 | import { useDevice } from "@core/stores/deviceStore.ts";
3 | import { useTranslation } from "react-i18next";
4 |
5 | export const KeyBackupReminder = () => {
6 | const { setDialogOpen } = useDevice();
7 | const { t } = useTranslation("dialog");
8 |
9 | useBackupReminder({
10 | message: t("pkiBackup.description"),
11 | onAccept: () => setDialogOpen("pkiBackup", true),
12 | enabled: true,
13 | });
14 | // deno-lint-ignore jsx-no-useless-fragment
15 | return <>>;
16 | };
17 |
--------------------------------------------------------------------------------
/src/components/Map.tsx:
--------------------------------------------------------------------------------
1 | import MapGl, {
2 | AttributionControl,
3 | GeolocateControl,
4 | type MapRef,
5 | NavigationControl,
6 | ScaleControl,
7 | } from "react-map-gl/maplibre";
8 | import { useTheme } from "@core/hooks/useTheme.ts";
9 | import { useEffect, useRef } from "react";
10 |
11 | interface MapProps {
12 | children?: React.ReactNode;
13 | onLoad?: (map: MapRef) => void;
14 | }
15 |
16 | export const Map = ({ children, onLoad }: MapProps) => {
17 | const { theme } = useTheme();
18 | const darkMode = theme === "dark";
19 | const mapRef = useRef(null);
20 |
21 | useEffect(() => {
22 | const map = mapRef.current;
23 | if (map && onLoad) onLoad(map);
24 | }, [onLoad]);
25 |
26 | return (
27 |
42 |
48 |
53 |
54 |
55 | {children}
56 |
57 | );
58 | };
59 |
--------------------------------------------------------------------------------
/src/components/PageComponents/Config/Bluetooth.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | type BluetoothValidation,
3 | BluetoothValidationSchema,
4 | } from "@app/validation/config/bluetooth.ts";
5 | import { create } from "@bufbuild/protobuf";
6 | import { DynamicForm } from "@components/Form/DynamicForm.tsx";
7 | import { useDevice } from "@core/stores/deviceStore.ts";
8 | import { Protobuf } from "@meshtastic/core";
9 | import { useTranslation } from "react-i18next";
10 |
11 | export const Bluetooth = () => {
12 | const { config, setWorkingConfig } = useDevice();
13 | const { t } = useTranslation("deviceConfig");
14 |
15 | const onSubmit = (data: BluetoothValidation) => {
16 | setWorkingConfig(
17 | create(Protobuf.Config.ConfigSchema, {
18 | payloadVariant: {
19 | case: "bluetooth",
20 | value: data,
21 | },
22 | }),
23 | );
24 | };
25 |
26 | return (
27 |
28 | onSubmit={onSubmit}
29 | validationSchema={BluetoothValidationSchema}
30 | formId="Config_BluetoothConfig"
31 | defaultValues={config.bluetooth}
32 | fieldGroups={[
33 | {
34 | label: t("bluetooth.title"),
35 | description: t("bluetooth.description"),
36 | notes: t("bluetooth.note"),
37 | fields: [
38 | {
39 | type: "toggle",
40 | name: "enabled",
41 | label: t("bluetooth.enabled.label"),
42 | description: t("bluetooth.enabled.description"),
43 | },
44 | {
45 | type: "select",
46 | name: "mode",
47 | label: t("bluetooth.pairingMode.label"),
48 | description: t("bluetooth.pairingMode.description"),
49 | disabledBy: [
50 | {
51 | fieldName: "enabled",
52 | },
53 | ],
54 | properties: {
55 | enumValue: Protobuf.Config.Config_BluetoothConfig_PairingMode,
56 | formatEnumName: true,
57 | },
58 | },
59 | {
60 | type: "number",
61 | name: "fixedPin",
62 | label: t("bluetooth.pin.label"),
63 | description: t("bluetooth.pin.description"),
64 | },
65 | ],
66 | },
67 | ]}
68 | />
69 | );
70 | };
71 |
--------------------------------------------------------------------------------
/src/components/PageComponents/Messages/ChannelChat.tsx:
--------------------------------------------------------------------------------
1 | import { MessageItem } from "@components/PageComponents/Messages/MessageItem.tsx";
2 | import { InboxIcon } from "lucide-react";
3 | import { Message } from "@core/stores/messageStore/types.ts";
4 | import { useTranslation } from "react-i18next";
5 |
6 | export interface ChannelChatProps {
7 | messages?: Message[];
8 | }
9 |
10 | const EmptyState = () => {
11 | const { t } = useTranslation("messages");
12 | return (
13 |
14 |
15 | {t("emptyState.text")}
16 |
17 | );
18 | };
19 |
20 | export const ChannelChat = ({ messages = [] }: ChannelChatProps) => {
21 | if (!messages?.length) {
22 | return (
23 |
24 |
25 |
26 | );
27 | }
28 |
29 | return (
30 |
31 | {messages?.map((message) => (
32 |
36 | ))}
37 |
38 | );
39 | };
40 |
--------------------------------------------------------------------------------
/src/components/PageComponents/Messages/MessageInput.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@components/UI/Button.tsx";
2 | import { Input } from "@components/UI/Input.tsx";
3 | import type { Types } from "@meshtastic/core";
4 | import { SendIcon } from "lucide-react";
5 | import { startTransition, useState } from "react";
6 | import { useMessageStore } from "@core/stores/messageStore/index.ts";
7 |
8 | export interface MessageInputProps {
9 | onSend: (message: string) => void;
10 | to: Types.Destination;
11 | maxBytes: number;
12 | }
13 |
14 | export const MessageInput = ({
15 | onSend,
16 | to,
17 | maxBytes,
18 | }: MessageInputProps) => {
19 | const { setDraft, getDraft, clearDraft } = useMessageStore();
20 |
21 | const calculateBytes = (text: string) => new Blob([text]).size;
22 |
23 | const initialDraft = getDraft(to);
24 | const [localDraft, setLocalDraft] = useState(initialDraft);
25 | const [messageBytes, setMessageBytes] = useState(() =>
26 | calculateBytes(initialDraft)
27 | );
28 |
29 | const handleInputChange = (e: React.ChangeEvent) => {
30 | const newValue = e.target.value;
31 | const byteLength = calculateBytes(newValue);
32 |
33 | if (byteLength <= maxBytes) {
34 | setLocalDraft(newValue);
35 | setMessageBytes(byteLength);
36 | setDraft(to, newValue);
37 | }
38 | };
39 |
40 | const handleSubmit = (e: React.FormEvent) => {
41 | e.preventDefault();
42 | if (!localDraft.trim()) return;
43 | // Reset bytes *before* sending (consider if onSend failure needs different handling)
44 | setMessageBytes(0);
45 |
46 | startTransition(() => {
47 | onSend(localDraft.trim());
48 | setLocalDraft("");
49 | clearDraft(to);
50 | });
51 | };
52 |
53 | return (
54 |
85 | );
86 | };
87 |
--------------------------------------------------------------------------------
/src/components/PageComponents/Messages/TraceRoute.tsx:
--------------------------------------------------------------------------------
1 | import { useDevice } from "@core/stores/deviceStore.ts";
2 | import type { Protobuf } from "@meshtastic/core";
3 | import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
4 | import { useTranslation } from "react-i18next";
5 |
6 | export interface TraceRouteProps {
7 | from?: Protobuf.Mesh.NodeInfo;
8 | to?: Protobuf.Mesh.NodeInfo;
9 | route: Array;
10 | routeBack?: Array;
11 | snrTowards?: Array;
12 | snrBack?: Array;
13 | }
14 |
15 | interface RoutePathProps {
16 | title: string;
17 | startNode?: Protobuf.Mesh.NodeInfo;
18 | endNode?: Protobuf.Mesh.NodeInfo;
19 | path: number[];
20 | snr?: number[];
21 | }
22 |
23 | const RoutePath = (
24 | { title, startNode, endNode, path, snr }: RoutePathProps,
25 | ) => {
26 | const { getNode } = useDevice();
27 | const { t } = useTranslation();
28 |
29 | return (
30 |
34 | {title}
35 | {startNode?.user?.longName}
36 |
37 | ↓ {snr?.[0] ?? t("unknown.num")}
38 | {t("unit.dbm")}
39 |
40 | {path.map((hop, i) => (
41 |
42 |
43 | {getNode(hop)?.user?.longName ??
44 | `${t("traceRoute.nodeUnknownPrefix")}${numberToHexUnpadded(hop)}`}
45 |
46 |
47 | ↓ {snr?.[i + 1] ?? t("unknown.num")}
48 | {t("unit.dbm")}
49 |
50 |
51 | ))}
52 | {endNode?.user?.longName}
53 |
54 | );
55 | };
56 |
57 | export const TraceRoute = ({
58 | from,
59 | to,
60 | route,
61 | routeBack,
62 | snrTowards,
63 | snrBack,
64 | }: TraceRouteProps) => {
65 | const { t } = useTranslation("dialog");
66 | return (
67 |
68 |
75 | {routeBack && routeBack.length > 0 && (
76 |
83 | )}
84 |
85 | );
86 | };
87 |
--------------------------------------------------------------------------------
/src/components/PageComponents/ModuleConfig/AmbientLighting.tsx:
--------------------------------------------------------------------------------
1 | import { useDevice } from "@core/stores/deviceStore.ts";
2 | import {
3 | type AmbientLightingValidation,
4 | AmbientLightingValidationSchema,
5 | } from "@app/validation/moduleConfig/ambientLighting.ts";
6 | import { create } from "@bufbuild/protobuf";
7 | import { DynamicForm } from "@components/Form/DynamicForm.tsx";
8 | import { Protobuf } from "@meshtastic/core";
9 | import { useTranslation } from "react-i18next";
10 |
11 | export const AmbientLighting = () => {
12 | const { moduleConfig, setWorkingModuleConfig } = useDevice();
13 | const { t } = useTranslation("moduleConfig");
14 |
15 | const onSubmit = (data: AmbientLightingValidation) => {
16 | setWorkingModuleConfig(
17 | create(Protobuf.ModuleConfig.ModuleConfigSchema, {
18 | payloadVariant: {
19 | case: "ambientLighting",
20 | value: data,
21 | },
22 | }),
23 | );
24 | };
25 |
26 | return (
27 |
28 | onSubmit={onSubmit}
29 | validationSchema={AmbientLightingValidationSchema}
30 | formId="ModuleConfig_AmbientLightingConfig"
31 | defaultValues={moduleConfig.ambientLighting}
32 | fieldGroups={[
33 | {
34 | label: t("ambientLighting.title"),
35 | description: t("ambientLighting.description"),
36 | fields: [
37 | {
38 | type: "toggle",
39 | name: "ledState",
40 | label: t("ambientLighting.ledState.label"),
41 | description: t("ambientLighting.ledState.description"),
42 | },
43 | {
44 | type: "number",
45 | name: "current",
46 | label: t("ambientLighting.current.label"),
47 | description: t("ambientLighting.current.description"),
48 | },
49 | {
50 | type: "number",
51 | name: "red",
52 | label: t("ambientLighting.red.label"),
53 | description: t("ambientLighting.red.description"),
54 | },
55 | {
56 | type: "number",
57 | name: "green",
58 | label: t("ambientLighting.green.label"),
59 | description: t("ambientLighting.green.description"),
60 | },
61 | {
62 | type: "number",
63 | name: "blue",
64 | label: t("ambientLighting.blue.label"),
65 | description: t("ambientLighting.blue.description"),
66 | },
67 | ],
68 | },
69 | ]}
70 | />
71 | );
72 | };
73 |
--------------------------------------------------------------------------------
/src/components/PageComponents/ModuleConfig/NeighborInfo.tsx:
--------------------------------------------------------------------------------
1 | import { useDevice } from "@core/stores/deviceStore.ts";
2 | import {
3 | type NeighborInfoValidation,
4 | NeighborInfoValidationSchema,
5 | } from "@app/validation/moduleConfig/neighborInfo.ts";
6 | import { create } from "@bufbuild/protobuf";
7 | import { DynamicForm } from "@components/Form/DynamicForm.tsx";
8 | import { Protobuf } from "@meshtastic/core";
9 | import { useTranslation } from "react-i18next";
10 |
11 | export const NeighborInfo = () => {
12 | const { moduleConfig, setWorkingModuleConfig } = useDevice();
13 | const { t } = useTranslation("moduleConfig");
14 |
15 | const onSubmit = (data: NeighborInfoValidation) => {
16 | setWorkingModuleConfig(
17 | create(Protobuf.ModuleConfig.ModuleConfigSchema, {
18 | payloadVariant: {
19 | case: "neighborInfo",
20 | value: data,
21 | },
22 | }),
23 | );
24 | };
25 |
26 | return (
27 |
28 | onSubmit={onSubmit}
29 | validationSchema={NeighborInfoValidationSchema}
30 | formId="ModuleConfig_NeighborInfoConfig"
31 | defaultValues={moduleConfig.neighborInfo}
32 | fieldGroups={[
33 | {
34 | label: t("neighborInfo.title"),
35 | description: t("neighborInfo.description"),
36 | fields: [
37 | {
38 | type: "toggle",
39 | name: "enabled",
40 | label: t("neighborInfo.enabled.label"),
41 | description: t("neighborInfo.enabled.description"),
42 | },
43 | {
44 | type: "number",
45 | name: "updateInterval",
46 | label: t("neighborInfo.updateInterval.label"),
47 | description: t("neighborInfo.updateInterval.description"),
48 | properties: {
49 | suffix: t("unit.second.plural"),
50 | },
51 | disabledBy: [
52 | {
53 | fieldName: "enabled",
54 | },
55 | ],
56 | },
57 | ],
58 | },
59 | ]}
60 | />
61 | );
62 | };
63 |
--------------------------------------------------------------------------------
/src/components/PageComponents/ModuleConfig/RangeTest.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | type RangeTestValidation,
3 | RangeTestValidationSchema,
4 | } from "@app/validation/moduleConfig/rangeTest.ts";
5 | import { create } from "@bufbuild/protobuf";
6 | import { DynamicForm } from "@components/Form/DynamicForm.tsx";
7 | import { useDevice } from "@core/stores/deviceStore.ts";
8 | import { Protobuf } from "@meshtastic/core";
9 | import { useTranslation } from "react-i18next";
10 |
11 | export const RangeTest = () => {
12 | const { moduleConfig, setWorkingModuleConfig } = useDevice();
13 | const { t } = useTranslation("moduleConfig");
14 |
15 | const onSubmit = (data: RangeTestValidation) => {
16 | setWorkingModuleConfig(
17 | create(Protobuf.ModuleConfig.ModuleConfigSchema, {
18 | payloadVariant: {
19 | case: "rangeTest",
20 | value: data,
21 | },
22 | }),
23 | );
24 | };
25 |
26 | return (
27 |
28 | onSubmit={onSubmit}
29 | validationSchema={RangeTestValidationSchema}
30 | formId="ModuleConfig_RangeTestConfig"
31 | defaultValues={moduleConfig.rangeTest}
32 | fieldGroups={[
33 | {
34 | label: t("rangeTest.title"),
35 | description: t("rangeTest.description"),
36 | fields: [
37 | {
38 | type: "toggle",
39 | name: "enabled",
40 | label: t("rangeTest.enabled.label"),
41 | description: t("rangeTest.enabled.description"),
42 | },
43 | {
44 | type: "number",
45 | name: "sender",
46 | label: t("rangeTest.sender.label"),
47 | description: t("rangeTest.sender.description"),
48 | properties: {
49 | suffix: t("unit.second.plural"),
50 | },
51 | disabledBy: [
52 | {
53 | fieldName: "enabled",
54 | },
55 | ],
56 | },
57 | {
58 | type: "toggle",
59 | name: "save",
60 | label: t("rangeTest.save.label"),
61 | description: t("rangeTest.save.description"),
62 | disabledBy: [
63 | {
64 | fieldName: "enabled",
65 | },
66 | ],
67 | },
68 | ],
69 | },
70 | ]}
71 | />
72 | );
73 | };
74 |
--------------------------------------------------------------------------------
/src/components/Toaster.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Toast,
3 | ToastClose,
4 | ToastDescription,
5 | ToastProvider,
6 | ToastTitle,
7 | ToastViewport,
8 | } from "@components/UI/Toast.tsx";
9 | import { useToast } from "@core/hooks/useToast.ts";
10 |
11 | export function Toaster() {
12 | const { toasts } = useToast();
13 |
14 | return (
15 |
16 | {toasts.map(({ id, title, description, action, duration, ...props }) => (
17 |
23 |
24 | {title && {title}}
25 | {description && {description}}
26 |
27 | {action}
28 |
29 |
30 | ))}
31 |
32 |
33 | );
34 | }
35 |
--------------------------------------------------------------------------------
/src/components/UI/Accordion.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@core/utils/cn.ts";
2 | import * as AccordionPrimitive from "@radix-ui/react-accordion";
3 | import { ChevronDownIcon } from "lucide-react";
4 | import { type ComponentRef, forwardRef } from "react";
5 |
6 | export const Accordion = AccordionPrimitive.Root;
7 |
8 | export const AccordionHeader = AccordionPrimitive.Header;
9 |
10 | export const AccordionItem = AccordionPrimitive.Item;
11 |
12 | export const AccordionTrigger = forwardRef<
13 | ComponentRef,
14 | React.ComponentPropsWithoutRef
15 | >(({ className, ...props }, ref) => (
16 |
24 | {props.children}
25 |
29 |
30 | ));
31 |
32 | export const AccordionContent = forwardRef<
33 | ComponentRef,
34 | React.ComponentPropsWithoutRef
35 | >(({ className, ...props }, ref) => (
36 |
44 | ));
45 |
--------------------------------------------------------------------------------
/src/components/UI/Footer.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@core/utils/cn.ts";
2 | import { Trans, useTranslation } from "react-i18next";
3 |
4 | type FooterProps = {
5 | className?: string;
6 | };
7 |
8 | const Footer = ({ className, ...props }: FooterProps) => {
9 | const { t } = useTranslation();
10 |
11 | return (
12 |
46 | );
47 | };
48 |
49 | export default Footer;
50 |
--------------------------------------------------------------------------------
/src/components/UI/Label.tsx:
--------------------------------------------------------------------------------
1 | import * as LabelPrimitive from "@radix-ui/react-label";
2 | import * as React from "react";
3 |
4 | import { cn } from "@core/utils/cn.ts";
5 |
6 | const Label = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, ...props }, ref) => (
10 |
18 | ));
19 | Label.displayName = LabelPrimitive.Root.displayName;
20 |
21 | export { Label };
22 |
--------------------------------------------------------------------------------
/src/components/UI/MultiSelect.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "../../core/utils/cn.ts";
2 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
3 | import { Check } from "lucide-react";
4 |
5 | interface MultiSelectProps {
6 | children: React.ReactNode;
7 | className?: string;
8 | }
9 |
10 | const MultiSelect = ({ children, className = "" }: MultiSelectProps) => {
11 | return {children}
12 |
;
13 | };
14 |
15 | interface MultiSelectItemProps {
16 | name: string;
17 | value: string;
18 | checked: boolean;
19 | onCheckedChange: (name: string, value: boolean) => void;
20 | children: React.ReactNode;
21 | className?: string;
22 | }
23 |
24 | const MultiSelectItem = ({
25 | name,
26 | value,
27 | checked,
28 | onCheckedChange,
29 | children,
30 | className = "",
31 | }: MultiSelectItemProps) => {
32 | return (
33 | onCheckedChange(name, !!val)}
38 | className={cn(
39 | `
40 | inline-flex items-center rounded-md px-3 py-2 text-sm transition-colors
41 | border border-slate-300
42 | hover:bg-slate-100 dark:hover:bg-slate-800
43 | focus:outline-hidden focus:ring-2 focus:ring-slate-400 focus:ring-offset-2
44 | data-[state=checked]:bg-slate-100 dark:data-[state=checked]:bg-slate-700`,
45 | className,
46 | )}
47 | >
48 |
49 |
50 |
51 | {children}
52 |
53 | );
54 | };
55 |
56 | export { MultiSelect, MultiSelectItem };
57 |
--------------------------------------------------------------------------------
/src/components/UI/Popover.tsx:
--------------------------------------------------------------------------------
1 | import * as PopoverPrimitive from "@radix-ui/react-popover";
2 | import * as React from "react";
3 |
4 | import { cn } from "@core/utils/cn.ts";
5 |
6 | const Popover = PopoverPrimitive.Root;
7 |
8 | const PopoverTrigger = PopoverPrimitive.Trigger;
9 |
10 | const PopoverContent = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
14 |
15 |
25 |
26 | ));
27 | PopoverContent.displayName = PopoverPrimitive.Content.displayName;
28 |
29 | export { Popover, PopoverContent, PopoverTrigger };
30 |
--------------------------------------------------------------------------------
/src/components/UI/ScrollArea.tsx:
--------------------------------------------------------------------------------
1 | import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
2 | import * as React from "react";
3 |
4 | import { cn } from "@core/utils/cn.ts";
5 |
6 | const ScrollArea = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, children, ...props }, ref) => (
10 |
15 |
16 | {children}
17 |
18 |
19 |
20 |
21 | ));
22 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
23 |
24 | const ScrollBar = React.forwardRef<
25 | React.ElementRef,
26 | React.ComponentPropsWithoutRef
27 | >(({ className, orientation = "vertical", ...props }, ref) => (
28 |
41 |
42 |
43 | ));
44 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
45 |
46 | export { ScrollArea, ScrollBar };
47 |
--------------------------------------------------------------------------------
/src/components/UI/Seperator.tsx:
--------------------------------------------------------------------------------
1 | import * as SeparatorPrimitive from "@radix-ui/react-separator";
2 | import * as React from "react";
3 |
4 | import { cn } from "@core/utils/cn.ts";
5 |
6 | const Separator = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(
10 | (
11 | { className, orientation = "horizontal", decorative = true, ...props },
12 | ref,
13 | ) => (
14 |
25 | ),
26 | );
27 | Separator.displayName = SeparatorPrimitive.Root.displayName;
28 |
29 | export { Separator };
30 |
--------------------------------------------------------------------------------
/src/components/UI/Sidebar/SidebarButton.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Button } from "@components/UI/Button.tsx";
3 | import type { LucideIcon } from "lucide-react";
4 | import { cn } from "@core/utils/cn.ts";
5 | import { useSidebar } from "@core/stores/sidebarStore.tsx";
6 |
7 | export interface SidebarButtonProps {
8 | label: string;
9 | count?: number;
10 | active?: boolean;
11 | Icon?: LucideIcon;
12 | children?: React.ReactNode;
13 | onClick?: () => void;
14 | disabled?: boolean;
15 | preventCollapse?: boolean;
16 | }
17 |
18 | export const SidebarButton = ({
19 | label,
20 | active,
21 | Icon,
22 | count,
23 | children,
24 | onClick,
25 | disabled = false,
26 | preventCollapse = false,
27 | }: SidebarButtonProps) => {
28 | const { isCollapsed: isSidebarCollapsed } = useSidebar();
29 | const isButtonCollapsed = isSidebarCollapsed && !preventCollapse;
30 |
31 | return (
32 |
80 | );
81 | };
82 |
--------------------------------------------------------------------------------
/src/components/UI/Sidebar/SidebarSection.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { cn } from "@core/utils/cn.ts";
3 | import { Heading } from "@components/UI/Typography/Heading.tsx";
4 | import { useSidebar } from "@core/stores/sidebarStore.tsx";
5 |
6 | interface SidebarSectionProps {
7 | label: string;
8 | children: React.ReactNode;
9 | className?: string;
10 | }
11 |
12 | export const SidebarSection = ({
13 | label,
14 | children,
15 | className,
16 | }: SidebarSectionProps) => {
17 | const { isCollapsed } = useSidebar();
18 | return (
19 |
26 |
38 | {label}
39 |
40 |
41 |
42 | {children}
43 |
44 |
45 | );
46 | };
47 |
--------------------------------------------------------------------------------
/src/components/UI/Slider.tsx:
--------------------------------------------------------------------------------
1 | import { useId, useState } from "react";
2 | import * as SliderPrimitive from "@radix-ui/react-slider";
3 | import { cn } from "@core/utils/cn.ts";
4 |
5 | export interface SliderProps {
6 | value: number[];
7 | step?: number;
8 | min?: number;
9 | max: number;
10 | onValueChange?: (value: number[]) => void;
11 | onValueCommit?: (value: number[]) => void;
12 | disabled?: boolean;
13 | className?: string;
14 | trackClassName?: string;
15 | rangeClassName?: string;
16 | thumbClassName?: string;
17 | }
18 |
19 | export function Slider({
20 | value,
21 | step = 1,
22 | min = 0,
23 | max,
24 | onValueChange,
25 | onValueCommit,
26 | disabled = false,
27 | className,
28 | trackClassName,
29 | rangeClassName,
30 | thumbClassName,
31 | ...props
32 | }: SliderProps) {
33 | const [internalValue, setInternalValue] = useState(value);
34 | const isControlled = value !== undefined;
35 | const currentValue = isControlled ? value! : internalValue;
36 | const id = useId();
37 |
38 | const handleValueChange = (newValue: number[]) => {
39 | if (!isControlled) setInternalValue(newValue);
40 | onValueChange?.(newValue);
41 | };
42 |
43 | const handleValueCommit = (newValue: number[]) => {
44 | onValueCommit?.(newValue);
45 | };
46 |
47 | return (
48 |
62 |
68 |
74 |
75 | {currentValue.map((_, i) => (
76 |
84 | ))}
85 |
86 | );
87 | }
88 |
--------------------------------------------------------------------------------
/src/components/UI/Spinner.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "../../core/utils/cn.ts";
2 |
3 | interface SpinnerProps extends React.HTMLAttributes {
4 | size?: "sm" | "md" | "lg";
5 | }
6 |
7 | const sizeClasses = {
8 | sm: "h-4 w-4",
9 | md: "h-8 w-8",
10 | lg: "h-12 w-12",
11 | };
12 |
13 | export function Spinner({ className, size = "md", ...props }: SpinnerProps) {
14 | return (
15 |
23 |
102 |
103 | );
104 | }
105 |
--------------------------------------------------------------------------------
/src/components/UI/Switch.tsx:
--------------------------------------------------------------------------------
1 | import * as SwitchPrimitives from "@radix-ui/react-switch";
2 | import * as React from "react";
3 |
4 | import { cn } from "@core/utils/cn.ts";
5 |
6 | const Switch = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, ...props }, ref) => (
10 |
18 |
23 |
24 | ));
25 | Switch.displayName = SwitchPrimitives.Root.displayName;
26 |
27 | export { Switch };
28 |
--------------------------------------------------------------------------------
/src/components/UI/Tabs.tsx:
--------------------------------------------------------------------------------
1 | import * as TabsPrimitive from "@radix-ui/react-tabs";
2 | import * as React from "react";
3 |
4 | import { cn } from "@core/utils/cn.ts";
5 |
6 | const Tabs = TabsPrimitive.Root;
7 |
8 | const TabsList = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 | ));
21 | TabsList.displayName = TabsPrimitive.List.displayName;
22 |
23 | const TabsTrigger = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef
26 | >(({ className, ...props }, ref) => (
27 |
35 | ));
36 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
37 |
38 | const TabsContent = React.forwardRef<
39 | React.ElementRef,
40 | React.ComponentPropsWithoutRef
41 | >(({ className, ...props }, ref) => (
42 |
50 | ));
51 | TabsContent.displayName = TabsPrimitive.Content.displayName;
52 |
53 | export { Tabs, TabsContent, TabsList, TabsTrigger };
54 |
--------------------------------------------------------------------------------
/src/components/UI/ToggleGroup.tsx:
--------------------------------------------------------------------------------
1 | import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
2 | import * as React from "react";
3 | import { cn } from "@core/utils/cn.ts";
4 |
5 | const toggleGroupItemClasses = [
6 | "flex flex-1 h-10 items-center justify-center first:rounded-l last:rounded-r ",
7 | "bg-slate-100",
8 | "dark:bg-slate-800",
9 | "data-[state=on]:bg-slate-600 data-[state=on]:text-white",
10 | "data-[state=on]:dark:bg-slate-950 data-[state=on]:text-white data-[state=on]:dark:text-slate-200",
11 | "data-[state=on]:hover:bg-slate-700 hover:bg-slate-700 hover:text-white hover:z-10 hover:shadow-[0_0_1px_2px] hover:outline-1 hover:outline-slate-700 hover:shadow-white/10",
12 | "data-[state=on]:dark:hover:bg-slate-700 dark:hover:bg-slate-700 dark:hover:text-slate-200 dark:hover:outline-slate-700 dark:hover:shadow-black/20",
13 | "disabled:text-slate-300 disabled:hover:bg-slate-100 disabled:hover:outline-none hover:shadow-none disabled:dark:text-slate-600 disabled:dark:hover:bg-slate-800 disabled:dark:hover:outline-none disabled:shadow-none",
14 | ];
15 |
16 | const ToggleGroup = React.forwardRef<
17 | React.ComponentRef,
18 | React.ComponentPropsWithoutRef
19 | >(({ className, ...props }, ref) => (
20 |
28 | ));
29 | ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName;
30 |
31 | const ToggleGroupItem = React.forwardRef<
32 | React.ComponentRef,
33 | React.ComponentPropsWithoutRef
34 | >(({ className, children, ...props }, ref) => (
35 |
43 | {children}
44 |
45 | ));
46 | ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName;
47 |
48 | export { ToggleGroup, ToggleGroupItem };
49 |
--------------------------------------------------------------------------------
/src/components/UI/Tooltip.tsx:
--------------------------------------------------------------------------------
1 | import * as TooltipPrimitive from "@radix-ui/react-tooltip";
2 | import * as React from "react";
3 |
4 | import { cn } from "@core/utils/cn.ts";
5 |
6 | const TooltipProvider = TooltipPrimitive.Provider;
7 |
8 | const Tooltip = ({ ...props }) => ;
9 | Tooltip.displayName = TooltipPrimitive.Tooltip.displayName;
10 |
11 | const TooltipTrigger = TooltipPrimitive.Trigger;
12 | const TooltipArrow = TooltipPrimitive.Arrow;
13 |
14 | const TooltipContent = React.forwardRef<
15 | React.ElementRef,
16 | React.ComponentPropsWithoutRef
17 | >(({ className, sideOffset = 4, ...props }, ref) => (
18 |
27 | ));
28 | TooltipContent.displayName = TooltipPrimitive.Content.displayName;
29 |
30 | export {
31 | Tooltip,
32 | TooltipArrow,
33 | TooltipContent,
34 | TooltipProvider,
35 | TooltipTrigger,
36 | };
37 |
--------------------------------------------------------------------------------
/src/components/UI/Typography/Blockquote.tsx:
--------------------------------------------------------------------------------
1 | export interface BlockquoteProps {
2 | children: React.ReactNode;
3 | }
4 |
5 | export const BlockQuote = ({ children }: BlockquoteProps) => (
6 |
7 | {children}
8 |
9 | );
10 |
--------------------------------------------------------------------------------
/src/components/UI/Typography/Code.tsx:
--------------------------------------------------------------------------------
1 | export interface CodeProps {
2 | children: React.ReactNode;
3 | }
4 |
5 | export const Code = ({ children }: CodeProps) => (
6 |
7 | {children}
8 |
9 | );
10 |
--------------------------------------------------------------------------------
/src/components/UI/Typography/Heading.tsx:
--------------------------------------------------------------------------------
1 | import type React from "react";
2 |
3 | const headingStyles = {
4 | h1: "scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl",
5 | h2:
6 | "scroll-m-20 border-b border-b-slate-200 pb-2 text-3xl font-semibold tracking-tight transition-colors first:mt-0 dark:border-b-slate-700",
7 | h3: "scroll-m-20 text-lg font-semibold tracking-tight",
8 | h4: "scroll-m-20 text-xl font-semibold tracking-tight",
9 | h5: "scroll-m-20 text-lg font-medium tracking-tight",
10 | };
11 |
12 | interface HeadingProps {
13 | as?: "h1" | "h2" | "h3" | "h4" | "h5";
14 | children: React.ReactNode;
15 | className?: string;
16 | }
17 |
18 | export const Heading = ({
19 | as: Component = "h1",
20 | children,
21 | className = "",
22 | ...props
23 | }: HeadingProps) => {
24 | const baseStyles = headingStyles[Component] || headingStyles.h1;
25 |
26 | return (
27 |
28 | {children}
29 |
30 | );
31 | };
32 |
--------------------------------------------------------------------------------
/src/components/UI/Typography/Link.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@core/utils/cn.ts";
2 | import {
3 | Link as RouterLink,
4 | LinkProps as RouterLinkProps,
5 | } from "@tanstack/react-router";
6 |
7 | export interface LinkProps extends RouterLinkProps {
8 | href: string;
9 | children?: React.ReactNode;
10 | className?: string;
11 | }
12 |
13 | export const Link = ({ href, children, className }: LinkProps) => (
14 |
23 | {children}
24 |
25 | );
26 |
--------------------------------------------------------------------------------
/src/components/UI/Typography/P.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@core/utils/cn.ts";
2 |
3 | export interface PProps {
4 | children: React.ReactNode;
5 | className?: string;
6 | }
7 |
8 | export const P = ({ children, className }: PProps) => (
9 | {children}
10 | );
11 |
--------------------------------------------------------------------------------
/src/components/UI/Typography/Subtle.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@core/utils/cn.ts";
2 |
3 | export interface SubtleProps {
4 | className?: string;
5 | children: React.ReactNode;
6 | }
7 |
8 | export const Subtle = ({ className, children }: SubtleProps) => (
9 |
10 | {children}
11 |
12 | );
13 |
--------------------------------------------------------------------------------
/src/components/generic/Blur.tsx:
--------------------------------------------------------------------------------
1 | export const Blur = () => {
2 | return (
3 |
7 | );
8 | };
9 |
--------------------------------------------------------------------------------
/src/components/generic/DeviceImage.tsx:
--------------------------------------------------------------------------------
1 | export interface DeviceImageProps {
2 | deviceType: string;
3 | className?: React.HTMLAttributes["className"];
4 | }
5 |
6 | const hardwareModelToFilename: { [key: string]: string } = {
7 | DIY_V1: "diy.svg",
8 | NANO_G2_ULTRA: "nano-g2-ultra.svg",
9 | TBEAM: "tbeam.svg",
10 | HELTEC_HT62: "heltec-ht62-esp32c3-sx1262.svg",
11 | RPI_PICO: "pico.svg",
12 | T_DECK: "t-deck.svg",
13 | HELTEC_MESH_NODE_T114: "heltec-mesh-node-t114.svg",
14 | HELTEC_MESH_NODE_T114_CASE: "heltec-mesh-node-t114-case.svg",
15 | HELTEC_V3: "heltec-v3.svg",
16 | HELTEC_V3_CASE: "heltec-v3-case.svg",
17 | HELTEC_VISION_MASTER_E213: "heltec-vision-master-e213.svg",
18 | HELTEC_VISION_MASTER_E290: "heltec-vision-master-e290.svg",
19 | HELTEC_VISION_MASTER_T190: "heltec-vision-master-t190.svg",
20 | HELTEC_WIRELESS_PAPER: "heltec-wireless-paper.svg",
21 | HELTEC_WIRELESS_PAPER_V1_0: "heltec-wireless-paper-V1_0.svg",
22 | HELTEC_WIRELESS_TRACKER: "heltec-wireless-tracker.svg",
23 | HELTEC_WIRELESS_TRACKER_V1_0: "heltec-wireless-tracker-V1-0.svg",
24 | HELTEC_WSL_V3: "heltec-wsl-v3.svg",
25 | TLORA_C6: "tlora-c6.svg",
26 | TLORA_T3_S3: "tlora-t3s3-v1.svg",
27 | TLORA_T3_S3_EPAPER: "tlora-t3s3-epaper.svg",
28 | TLORA_V2: "tlora-v2-1-1_6.svg",
29 | TLORA_V2_1_1P6: "tlora-v2-1-1_6.svg",
30 | TLORA_V2_1_1P8: "tlora-v2-1-1_8.svg",
31 | RAK11310: "rak11310.svg",
32 | RAK2560: "rak2560.svg",
33 | RAK4631: "rak4631.svg",
34 | RAK4631_CASE: "rak4631_case.svg",
35 | WIO_WM1110: "wio-tracker-wm1110.svg",
36 | WM1110_DEV_KIT: "wm1110_dev_kit.svg",
37 | STATION_G2: "station-g2.svg",
38 | TBEAM_V0P7: "tbeam-s3-core.svg",
39 | T_ECHO: "t-echo.svg",
40 | TRACKER_T1000_E: "tracker-t1000-e.svg",
41 | T_WATCH_S3: "t-watch-s3.svg",
42 | SEEED_XIAO_S3: "seeed-xiao-s3.svg",
43 | SENSECAP_INDICATOR: "seeed-sensecap-indicator.svg",
44 | PROMICRO: "promicro.svg",
45 | RPIPICOW: "rpipicow.svg",
46 | UNKNOWN: "unknown.svg",
47 | };
48 |
49 | export const DeviceImage = ({ deviceType, className }: DeviceImageProps) => {
50 | const getPath = (device: string) => `/${device}`;
51 | const device = hardwareModelToFilename[deviceType] || "unknown.svg";
52 | return
;
53 | };
54 |
--------------------------------------------------------------------------------
/src/components/generic/Mono.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@core/utils/cn.ts";
2 |
3 | interface MonoProps extends React.HTMLAttributes {
4 | children: React.ReactNode;
5 | className?: string;
6 | }
7 | export const Mono = ({
8 | children,
9 | className,
10 | ...rest
11 | }: MonoProps) => {
12 | return (
13 |
17 | {children}
18 |
19 | );
20 | };
21 |
--------------------------------------------------------------------------------
/src/components/generic/TimeAgo.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Tooltip,
3 | TooltipContent,
4 | TooltipPortal,
5 | TooltipProvider,
6 | TooltipTrigger,
7 | } from "@radix-ui/react-tooltip";
8 |
9 | export interface TimeAgoProps {
10 | timestamp: number;
11 | }
12 |
13 | const getTimeAgo = (
14 | unixTimestamp: number,
15 | locale: Intl.LocalesArgument = "en",
16 | ): string => {
17 | const timestamp = new Date(unixTimestamp);
18 | const diff = (new Date().getTime() - timestamp.getTime()) / 1000;
19 |
20 | const minutes = Math.floor(diff / 60);
21 | const hours = Math.floor(minutes / 60);
22 | const days = Math.floor(hours / 24);
23 | const months = Math.floor(days / 30);
24 | const years = Math.floor(months / 12);
25 | const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
26 |
27 | if (years > 0) {
28 | return rtf.format(0 - years, "year");
29 | }
30 | if (months > 0) {
31 | return rtf.format(0 - months, "month");
32 | }
33 | if (days > 0) {
34 | return rtf.format(0 - days, "day");
35 | }
36 | if (hours > 0) {
37 | return rtf.format(0 - hours, "hour");
38 | }
39 | if (minutes > 0) {
40 | return rtf.format(0 - minutes, "minute");
41 | }
42 | return rtf.format(Math.floor(0 - diff), "second");
43 | };
44 |
45 | export const TimeAgo = ({ timestamp }: TimeAgoProps) => {
46 | return (
47 |
48 |
49 |
50 | {getTimeAgo(timestamp)}
51 |
52 |
53 |
59 | {new Date(timestamp).toLocaleString()}
60 |
61 |
62 |
63 |
64 | );
65 | };
66 |
--------------------------------------------------------------------------------
/src/components/generic/Uptime.tsx:
--------------------------------------------------------------------------------
1 | export interface UptimeProps {
2 | seconds: number;
3 | }
4 |
5 | const getUptime = (seconds: number): string => {
6 | const days = Math.floor(seconds / 86400);
7 | const hours = Math.floor((seconds % 86400) / 3600);
8 | const minutes = Math.floor(((seconds % 86400) % 3600) / 60);
9 | const secondsLeft = Math.floor(((seconds % 86400) % 3600) % 60);
10 | return `${days}d ${hours}h ${minutes}m ${secondsLeft}s`;
11 | };
12 |
13 | export const Uptime = ({ seconds }: UptimeProps) => {
14 | return {getUptime(seconds)};
15 | };
16 |
--------------------------------------------------------------------------------
/src/components/types.ts:
--------------------------------------------------------------------------------
1 | export type DeviceMetrics = {
2 | batteryLevel?: number | null;
3 | voltage?: number | null;
4 | };
5 |
--------------------------------------------------------------------------------
/src/core/dto/NodeNumToNodeInfoDTO.ts:
--------------------------------------------------------------------------------
1 | import { create } from "@bufbuild/protobuf";
2 | import { Protobuf } from "@meshtastic/core";
3 |
4 | class NodeInfoFactory {
5 | private static createDefaultUser(num: number): Protobuf.Mesh.User {
6 | const userIdHex = num.toString(16).toUpperCase().padStart(2, "0");
7 | const userId = `!${userIdHex}`;
8 | const last4 = userIdHex.slice(-4);
9 | const longName = `Meshtastic ${last4}`;
10 | const shortName = last4;
11 | const hwModel = Protobuf.Mesh.HardwareModel.UNSET;
12 |
13 | return create(Protobuf.Mesh.UserSchema, {
14 | id: userId,
15 | longName: longName,
16 | shortName: shortName,
17 | hwModel: hwModel,
18 | isLicensed: false,
19 | });
20 | }
21 |
22 | public static ensureDefaultUser(
23 | node: Protobuf.Mesh.NodeInfo,
24 | ): Protobuf.Mesh.NodeInfo {
25 | if (!node) {
26 | return node;
27 | }
28 |
29 | if (!node.user) {
30 | if (node.num === undefined || node.num === null) {
31 | console.error(
32 | `NodeInfoFactory.ensureDefaultUser: Cannot create default user for node because 'num' is missing.`,
33 | node,
34 | );
35 | return node;
36 | }
37 |
38 | node.user = NodeInfoFactory.createDefaultUser(node.num);
39 | }
40 |
41 | return node;
42 | }
43 | }
44 |
45 | export default NodeInfoFactory;
46 |
--------------------------------------------------------------------------------
/src/core/dto/PacketToMessageDTO.ts:
--------------------------------------------------------------------------------
1 | import type { Types } from "@meshtastic/js";
2 | import {
3 | Message,
4 | MessageState,
5 | MessageType,
6 | } from "../stores/messageStore/index.ts";
7 |
8 | class PacketToMessageDTO {
9 | channel: Types.ChannelNumber;
10 | to: number;
11 | from: number;
12 | date: number; // (timestamp ms)
13 | messageId: number;
14 | state: MessageState;
15 | message: string;
16 | type: MessageType;
17 |
18 | constructor(data: Types.PacketMetadata, nodeNum: number) {
19 | this.channel = data.channel;
20 | this.to = data.to;
21 | this.from = data.from;
22 | this.messageId = data.id;
23 | this.state = data.from !== nodeNum
24 | ? MessageState.Ack
25 | : MessageState.Waiting;
26 | this.message = data.data;
27 | this.type = (data.type === "direct")
28 | ? MessageType.Direct
29 | : MessageType.Broadcast;
30 |
31 | let dateTimestamp = Date.now();
32 | if (data.rxTime instanceof Date) {
33 | const timeValue = data.rxTime.getTime();
34 |
35 | if (!isNaN(timeValue)) {
36 | dateTimestamp = timeValue;
37 | }
38 | } else if (data.rxTime != null) {
39 | console.warn(
40 | `Received rxTime in PacketToMessageDTO was not a Date object as expected (type: ${typeof data
41 | .rxTime}, value: ${data.rxTime}). Using current time as fallback.`,
42 | );
43 | }
44 | this.date = dateTimestamp;
45 | }
46 |
47 | toMessage(): Message {
48 | return {
49 | channel: this.channel,
50 | to: this.to,
51 | from: this.from,
52 | date: this.date,
53 | messageId: this.messageId,
54 | state: this.state,
55 | message: this.message,
56 | type: this.type,
57 | };
58 | }
59 | }
60 |
61 | export default PacketToMessageDTO;
62 |
--------------------------------------------------------------------------------
/src/core/hooks/useBrowserFeatureDetection.ts:
--------------------------------------------------------------------------------
1 | import { useMemo } from "react";
2 |
3 | export type BrowserFeature = "Web Bluetooth" | "Web Serial" | "Secure Context";
4 |
5 | interface BrowserSupport {
6 | supported: BrowserFeature[];
7 | unsupported: BrowserFeature[];
8 | }
9 |
10 | export function useBrowserFeatureDetection(): BrowserSupport {
11 | const support = useMemo(() => {
12 | const features: [BrowserFeature, boolean][] = [
13 | ["Web Bluetooth", !!navigator?.bluetooth],
14 | ["Web Serial", !!navigator?.serial],
15 | [
16 | "Secure Context",
17 | globalThis.location.protocol === "https:" ||
18 | globalThis.location.hostname === "localhost",
19 | ],
20 | ];
21 |
22 | return features.reduce(
23 | (acc, [feature, isSupported]) => {
24 | const list = isSupported ? acc.supported : acc.unsupported;
25 | list.push(feature);
26 | return acc;
27 | },
28 | { supported: [], unsupported: [] },
29 | );
30 | }, []);
31 |
32 | return support;
33 | }
34 |
--------------------------------------------------------------------------------
/src/core/hooks/useCookie.ts:
--------------------------------------------------------------------------------
1 | import Cookies, { type CookieAttributes } from "js-cookie";
2 | import { useCallback, useState } from "react";
3 |
4 | interface CookieHookResult {
5 | value: T | undefined;
6 | setCookie: (value: T, options?: CookieAttributes) => void;
7 | removeCookie: () => void;
8 | }
9 |
10 | function useCookie(
11 | cookieName: string,
12 | initialValue?: T,
13 | ): CookieHookResult {
14 | const [cookieValue, setCookieValue] = useState(() => {
15 | try {
16 | const cookie = Cookies.get(cookieName);
17 | return cookie ? (JSON.parse(cookie) as T) : initialValue;
18 | } catch (error) {
19 | console.error(`Error parsing cookie ${cookieName}:`, error);
20 | return initialValue;
21 | }
22 | });
23 |
24 | const setCookie = useCallback(
25 | (value: T, options?: CookieAttributes) => {
26 | try {
27 | Cookies.set(cookieName, JSON.stringify(value), options);
28 | setCookieValue(value);
29 | } catch (error) {
30 | console.error(`Error setting cookie ${cookieName}:`, error);
31 | }
32 | },
33 | [cookieName],
34 | );
35 |
36 | const removeCookie = useCallback(() => {
37 | try {
38 | Cookies.remove(cookieName);
39 | setCookieValue(undefined);
40 | } catch (error) {
41 | console.error(`Error removing cookie ${cookieName}:`, error);
42 | }
43 | }, [cookieName]);
44 |
45 | return {
46 | value: cookieValue,
47 | setCookie,
48 | removeCookie,
49 | };
50 | }
51 |
52 | export default useCookie;
53 |
--------------------------------------------------------------------------------
/src/core/hooks/useCopyToClipboard.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useRef, useState } from "react";
2 |
3 | interface UseCopyToClipboardProps {
4 | timeout?: number;
5 | }
6 |
7 | export function useCopyToClipboard(
8 | { timeout = 2000 }: UseCopyToClipboardProps = {},
9 | ) {
10 | const [isCopied, setIsCopied] = useState(false);
11 | const timeoutRef = useRef(null);
12 |
13 | useEffect(() => {
14 | return () => {
15 | if (timeoutRef.current) {
16 | globalThis.clearTimeout(timeoutRef.current);
17 | }
18 | };
19 | }, []);
20 |
21 | const copy = useCallback(
22 | async (text: string) => {
23 | if (!navigator?.clipboard) {
24 | console.warn("Clipboard API not available");
25 | setIsCopied(false);
26 | return false;
27 | }
28 |
29 | if (timeoutRef.current) {
30 | globalThis.clearTimeout(timeoutRef.current);
31 | }
32 |
33 | try {
34 | await navigator.clipboard.writeText(text);
35 | setIsCopied(true);
36 |
37 | timeoutRef.current = globalThis.setTimeout(() => {
38 | setIsCopied(false);
39 | timeoutRef.current = null;
40 | }, timeout);
41 |
42 | return true;
43 | } catch (error) {
44 | console.error("Failed to copy text to clipboard:", error);
45 | setIsCopied(false);
46 | return false;
47 | }
48 | },
49 | [timeout],
50 | );
51 |
52 | return { isCopied, copy };
53 | }
54 |
--------------------------------------------------------------------------------
/src/core/hooks/useFavoriteNode.ts:
--------------------------------------------------------------------------------
1 | import { useCallback } from "react";
2 | import { useDevice } from "@core/stores/deviceStore.ts";
3 | import { useToast } from "@core/hooks/useToast.ts";
4 |
5 | interface FavoriteNodeOptions {
6 | nodeNum: number;
7 | isFavorite: boolean;
8 | }
9 |
10 | export function useFavoriteNode() {
11 | const { updateFavorite, getNode } = useDevice();
12 | const { toast } = useToast();
13 |
14 | const updateFavoriteCB = useCallback(
15 | ({ nodeNum, isFavorite }: FavoriteNodeOptions) => {
16 | const node = getNode(nodeNum);
17 | if (!node) return;
18 |
19 | updateFavorite(nodeNum, isFavorite);
20 |
21 | toast({
22 | title: `${isFavorite ? "Added" : "Removed"} ${
23 | node?.user?.longName ?? "node"
24 | } ${isFavorite ? "to" : "from"} favorites`,
25 | });
26 | },
27 | [updateFavorite, getNode],
28 | );
29 |
30 | return { updateFavorite: updateFavoriteCB };
31 | }
32 |
--------------------------------------------------------------------------------
/src/core/hooks/useIgnoreNode.ts:
--------------------------------------------------------------------------------
1 | import { useCallback } from "react";
2 | import { useDevice } from "@core/stores/deviceStore.ts";
3 | import { useToast } from "@core/hooks/useToast.ts";
4 |
5 | interface IgnoreNodeOptions {
6 | nodeNum: number;
7 | isIgnored: boolean;
8 | }
9 |
10 | export function useIgnoreNode() {
11 | const { updateIgnored, getNode } = useDevice();
12 | const { toast } = useToast();
13 |
14 | const updateIgnoredCB = useCallback(
15 | ({ nodeNum, isIgnored }: IgnoreNodeOptions) => {
16 | const node = getNode(nodeNum);
17 | if (!node) return;
18 |
19 | updateIgnored(nodeNum, isIgnored);
20 |
21 | toast({
22 | title: `${isIgnored ? "Added" : "Removed"} ${
23 | node?.user?.longName ?? "node"
24 | } ${isIgnored ? "to" : "from"} ignore list`,
25 | });
26 | },
27 | [updateIgnored, getNode],
28 | );
29 |
30 | return { updateIgnored: updateIgnoredCB };
31 | }
32 |
--------------------------------------------------------------------------------
/src/core/hooks/useLang.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useMemo } from "react";
2 | import { useTranslation } from "react-i18next";
3 | import { LangCode } from "@app/i18n/config.ts";
4 | import useLocalStorage from "./useLocalStorage.ts";
5 |
6 | /**
7 | * Hook to set the i18n language
8 | *
9 | * @returns The `set` function
10 | */
11 | const STORAGE_KEY = "language";
12 |
13 | type LanguageState = {
14 | language: string;
15 | };
16 | function useLang() {
17 | const { i18n } = useTranslation();
18 | const [_, setLanguage] = useLocalStorage(
19 | STORAGE_KEY,
20 | null,
21 | );
22 |
23 | const regionNames = useMemo(() => {
24 | return new Intl.DisplayNames(i18n.language, {
25 | type: "region",
26 | fallback: "none",
27 | style: "long",
28 | });
29 | }, [i18n.language]);
30 |
31 | const collator = useMemo(() => {
32 | return new Intl.Collator(i18n.language, {});
33 | }, [i18n.language]);
34 |
35 | /**
36 | * Sets the i18n language.
37 | *
38 | * @param lng - The language tag to set
39 | */
40 | const set = useCallback(
41 | async (lng: LangCode, persist = true) => {
42 | if (i18n.language === lng) {
43 | return;
44 | }
45 | console.info("set language:", lng);
46 | if (persist) {
47 | try {
48 | setLanguage({ language: lng });
49 | } catch (e) {
50 | console.warn(e);
51 | }
52 | await i18n.changeLanguage(lng);
53 | }
54 | },
55 | [i18n],
56 | );
57 |
58 | /**
59 | * Get the localized country name
60 | *
61 | * @param code - Two-letter country code
62 | */
63 | const getCountryName = useCallback(
64 | (code: LangCode) => {
65 | let name = null;
66 | try {
67 | name = regionNames.of(code);
68 | } catch (e) {
69 | console.warn(e);
70 | }
71 | return name;
72 | },
73 | [regionNames],
74 | );
75 |
76 | /**
77 | * Compare two strings according to the sort order of the current language
78 | *
79 | * @param a - The first string to compare
80 | * @param b - The second string to compare
81 | */
82 | const compare = useCallback(
83 | (a: string, b: string) => {
84 | return collator.compare(a, b);
85 | },
86 | [collator],
87 | );
88 |
89 | return { compare, set, getCountryName };
90 | }
91 |
92 | export default useLang;
93 |
--------------------------------------------------------------------------------
/src/core/hooks/useLocalStorage.test.ts:
--------------------------------------------------------------------------------
1 | import { act, renderHook } from "@testing-library/react";
2 | import useLocalStorage from "./useLocalStorage.ts";
3 | import { beforeEach, describe, expect, it } from "vitest";
4 |
5 | describe("useLocalStorage", () => {
6 | const key = "test-key";
7 |
8 | beforeEach(() => {
9 | localStorage.clear();
10 | });
11 |
12 | it("should initialize with initial value if localStorage is empty", () => {
13 | const { result } = renderHook(() => useLocalStorage(key, "initial"));
14 | const [value] = result.current;
15 | expect(value).toBe("initial");
16 | });
17 |
18 | it("should read existing value from localStorage", () => {
19 | localStorage.setItem(key, JSON.stringify("stored"));
20 | const { result } = renderHook(() => useLocalStorage(key, "initial"));
21 | const [value] = result.current;
22 | expect(value).toBe("stored");
23 | });
24 |
25 | it("should update localStorage when setValue is called", () => {
26 | const { result } = renderHook(() => useLocalStorage(key, "initial"));
27 | const [, setValue] = result.current;
28 |
29 | act(() => {
30 | setValue("updated");
31 | });
32 |
33 | expect(localStorage.getItem(key)).toBe(JSON.stringify("updated"));
34 | expect(result.current[0]).toBe("updated");
35 | });
36 |
37 | it("should remove value from localStorage when removeValue is called", () => {
38 | const { result } = renderHook(() => useLocalStorage(key, "initial"));
39 | const [, setValue, removeValue] = result.current;
40 |
41 | act(() => {
42 | setValue("to-be-removed");
43 | });
44 |
45 | act(() => {
46 | removeValue();
47 | });
48 |
49 | expect(localStorage.getItem(key)).toBeNull();
50 | expect(result.current[0]).toBe("initial");
51 | });
52 | });
53 |
--------------------------------------------------------------------------------
/src/core/hooks/usePasswordVisibilityToggle.test.ts:
--------------------------------------------------------------------------------
1 | import { act, renderHook } from "@testing-library/react";
2 | import { describe, expect, it } from "vitest";
3 | import { usePasswordVisibilityToggle } from "./usePasswordVisibilityToggle.ts";
4 |
5 | describe("usePasswordVisibilityToggle Hook", () => {
6 | it("should initialize with visibility set to false by default", () => {
7 | const { result } = renderHook(() => usePasswordVisibilityToggle());
8 | expect(result.current.isVisible).toBe(false);
9 | expect(typeof result.current.toggleVisibility).toBe("function");
10 | });
11 |
12 | it("should initialize with visibility set to true if initialVisible is true", () => {
13 | const { result } = renderHook(() =>
14 | usePasswordVisibilityToggle({ initialVisible: true })
15 | );
16 | expect(result.current.isVisible).toBe(true);
17 | });
18 |
19 | it("should toggle visibility from false to true when toggleVisibility is called", () => {
20 | const { result } = renderHook(() => usePasswordVisibilityToggle());
21 | expect(result.current.isVisible).toBe(false);
22 | act(() => {
23 | result.current.toggleVisibility();
24 | });
25 | expect(result.current.isVisible).toBe(true);
26 | });
27 |
28 | it("should toggle visibility from true to false when toggleVisibility is called", () => {
29 | const { result } = renderHook(() =>
30 | usePasswordVisibilityToggle({ initialVisible: true })
31 | );
32 | expect(result.current.isVisible).toBe(true);
33 | act(() => {
34 | result.current.toggleVisibility();
35 | });
36 | expect(result.current.isVisible).toBe(false);
37 | });
38 |
39 | it("should toggle visibility correctly multiple times", () => {
40 | const { result } = renderHook(() => usePasswordVisibilityToggle());
41 | expect(result.current.isVisible).toBe(false);
42 | act(() => {
43 | result.current.toggleVisibility();
44 | });
45 | expect(result.current.isVisible).toBe(true);
46 | act(() => {
47 | result.current.toggleVisibility();
48 | });
49 | expect(result.current.isVisible).toBe(false);
50 | act(() => {
51 | result.current.toggleVisibility();
52 | });
53 | expect(result.current.isVisible).toBe(true);
54 | });
55 |
56 | it("should return a stable toggleVisibility function reference (due to useCallback)", () => {
57 | const { result, rerender } = renderHook(() =>
58 | usePasswordVisibilityToggle()
59 | );
60 | const initialToggleFunc = result.current.toggleVisibility;
61 | rerender();
62 | expect(result.current.toggleVisibility).toBe(initialToggleFunc);
63 | act(() => {
64 | result.current.toggleVisibility();
65 | });
66 | expect(result.current.isVisible).toBe(true);
67 | });
68 | });
69 |
--------------------------------------------------------------------------------
/src/core/hooks/usePasswordVisibilityToggle.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useState } from "react";
2 |
3 | interface UsePasswordVisibilityToggleProps {
4 | initialVisible?: boolean;
5 | }
6 | /**
7 | * Manages the state for toggling password visibility.
8 | *
9 | * @param {boolean} [options.initialVisible=false]
10 | * @returns {{isVisible: boolean, toggleVisibility: () => void}}
11 | */
12 | export function usePasswordVisibilityToggle(
13 | { initialVisible = false }: UsePasswordVisibilityToggleProps = {},
14 | ) {
15 | const [isVisible, setIsVisible] = useState(initialVisible);
16 |
17 | const toggleVisibility = useCallback(() => {
18 | setIsVisible((prev) => !prev);
19 | }, []);
20 |
21 | return { isVisible, toggleVisibility };
22 | }
23 |
--------------------------------------------------------------------------------
/src/core/hooks/usePinnedItems.test.ts:
--------------------------------------------------------------------------------
1 | import { act, renderHook } from "@testing-library/react";
2 | import { beforeEach, describe, expect, it, vi } from "vitest";
3 | import { usePinnedItems } from "./usePinnedItems.ts";
4 |
5 | const mockSetPinnedItems = vi.fn();
6 | const mockUseLocalStorage = vi.fn();
7 |
8 | vi.mock("@core/hooks/useLocalStorage.ts", () => ({
9 | default: (...args) => mockUseLocalStorage(...args),
10 | }));
11 |
12 | describe("usePinnedItems", () => {
13 | beforeEach(() => {
14 | vi.clearAllMocks();
15 | });
16 |
17 | it("returns default pinnedItems and togglePinnedItem", () => {
18 | mockUseLocalStorage.mockReturnValue([[], mockSetPinnedItems]);
19 |
20 | const { result } = renderHook(() =>
21 | usePinnedItems({ storageName: "test-storage" })
22 | );
23 |
24 | expect(result.current.pinnedItems).toEqual([]);
25 | expect(typeof result.current.togglePinnedItem).toBe("function");
26 | });
27 |
28 | it("adds an item if it's not already pinned", () => {
29 | mockUseLocalStorage.mockReturnValue([["item1"], mockSetPinnedItems]);
30 |
31 | const { result } = renderHook(() =>
32 | usePinnedItems({ storageName: "test-storage" })
33 | );
34 |
35 | act(() => {
36 | result.current.togglePinnedItem("item2");
37 | });
38 |
39 | expect(mockSetPinnedItems).toHaveBeenCalledWith(expect.any(Function));
40 |
41 | const updater = mockSetPinnedItems.mock.calls[0][0];
42 | const updated = updater(["item1"]);
43 |
44 | expect(updated).toEqual(["item1", "item2"]);
45 | });
46 |
47 | it("removes an item if it's already pinned", () => {
48 | mockUseLocalStorage.mockReturnValue([
49 | ["item1", "item2"],
50 | mockSetPinnedItems,
51 | ]);
52 |
53 | const { result } = renderHook(() =>
54 | usePinnedItems({ storageName: "test-storage" })
55 | );
56 |
57 | act(() => {
58 | result.current.togglePinnedItem("item1");
59 | });
60 |
61 | expect(mockSetPinnedItems).toHaveBeenCalledWith(expect.any(Function));
62 |
63 | const updater = mockSetPinnedItems.mock.calls[0][0];
64 | const updated = updater(["item1", "item2"]);
65 |
66 | expect(updated).toEqual(["item2"]);
67 | });
68 | });
69 |
--------------------------------------------------------------------------------
/src/core/hooks/usePinnedItems.ts:
--------------------------------------------------------------------------------
1 | import useLocalStorage from "@core/hooks/useLocalStorage.ts";
2 | import { useCallback } from "react";
3 |
4 | export function usePinnedItems({ storageName }: { storageName: string }) {
5 | const [pinnedItems, setPinnedItems] = useLocalStorage(
6 | storageName,
7 | [],
8 | );
9 |
10 | const togglePinnedItem = useCallback((label: string) => {
11 | setPinnedItems((prev) =>
12 | prev.includes(label) ? prev.filter((g) => g !== label) : [...prev, label]
13 | );
14 | }, []);
15 |
16 | return {
17 | pinnedItems,
18 | togglePinnedItem,
19 | };
20 | }
21 |
--------------------------------------------------------------------------------
/src/core/hooks/useTheme.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useState } from "react";
2 |
3 | type Theme = "light" | "dark" | "system";
4 |
5 | export function useTheme() {
6 | const getSystemTheme = () =>
7 | globalThis.matchMedia("(prefers-color-scheme: dark)").matches
8 | ? "dark"
9 | : "light";
10 |
11 | const getStoredPreference = useCallback(
12 | (): Theme => (localStorage.getItem("theme") as Theme) || "system",
13 | [],
14 | );
15 |
16 | const [preference, setPreference] = useState(() =>
17 | typeof window !== "undefined" ? getStoredPreference() : "light"
18 | );
19 |
20 | const theme = preference === "system" ? getSystemTheme() : preference;
21 |
22 | useEffect(() => {
23 | document.documentElement.setAttribute("data-theme", theme);
24 | }, [theme]);
25 |
26 | useEffect(() => {
27 | if (preference !== "system") return;
28 |
29 | const media = globalThis.matchMedia("(prefers-color-scheme: dark)");
30 | const updateTheme = () => setPreference(getStoredPreference());
31 |
32 | media.addEventListener("change", updateTheme);
33 | return () => media.removeEventListener("change", updateTheme);
34 | }, [preference, getStoredPreference]);
35 |
36 | const setPreferenceValue = (newPreference: Theme) => {
37 | localStorage.setItem("theme", newPreference);
38 | setPreference(newPreference);
39 | };
40 |
41 | return { theme, preference, setPreference: setPreferenceValue };
42 | }
43 |
--------------------------------------------------------------------------------
/src/core/hooks/useToast.test.tsx:
--------------------------------------------------------------------------------
1 | import { act, renderHook } from "@testing-library/react";
2 | import { useToast } from "@core/hooks/useToast.ts";
3 | import { Button } from "@components/UI/Button.tsx";
4 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5 |
6 | describe("useToast", () => {
7 | beforeEach(() => {
8 | // Reset toast memory state before each test
9 | // our hook uses global memory to store toasts
10 | // @ts-expect-error - internal test reset
11 | globalThis.memoryState = { toasts: [] };
12 | vi.useFakeTimers();
13 | });
14 |
15 | afterEach(() => {
16 | vi.useRealTimers();
17 | });
18 |
19 | it("should create a toast with title, description, and action", () => {
20 | const { result } = renderHook(() => useToast());
21 |
22 | act(() => {
23 | result.current.toast({
24 | title: "Backup Reminder",
25 | description: "Don't forget to backup!",
26 | action: ,
27 | });
28 | vi.runAllTimers();
29 | });
30 |
31 | const toast = result.current.toasts[0];
32 | expect(result.current.toasts.length).toBe(1);
33 | expect(toast.title).toBe("Backup Reminder");
34 | expect(toast.description).toBe("Don't forget to backup!");
35 | expect(toast.action).toBeTruthy();
36 | expect(toast.open).toBe(true);
37 | });
38 | it("should dismiss a toast using returned dismiss function", () => {
39 | const { result } = renderHook(() => useToast());
40 | vi.useFakeTimers();
41 |
42 | let toastRef: { id: string; dismiss: () => void };
43 |
44 | act(() => {
45 | toastRef = result.current.toast({ title: "Dismiss Me" });
46 | vi.runAllTimers(); // Flush ADD_TOAST
47 | });
48 |
49 | act(() => {
50 | toastRef.dismiss();
51 | });
52 |
53 | const toast = result.current.toasts.find((t) => t.id === toastRef.id);
54 | expect(toast?.open).toBe(false);
55 |
56 | vi.useRealTimers();
57 | });
58 |
59 | it("should allow dismiss via hook dismiss function", () => {
60 | const { result } = renderHook(() => useToast());
61 | vi.useFakeTimers();
62 |
63 | let toastRef: { id: string };
64 |
65 | act(() => {
66 | toastRef = result.current.toast({ title: "Manual Dismiss" });
67 | vi.runAllTimers();
68 | });
69 |
70 | act(() => {
71 | result.current.dismiss(toastRef.id);
72 | });
73 |
74 | const toast = result.current.toasts.find((t) => t.id === toastRef.id);
75 | expect(toast?.open).toBe(false);
76 |
77 | vi.useRealTimers();
78 | });
79 | });
80 |
--------------------------------------------------------------------------------
/src/core/stores/messageStore/types.ts:
--------------------------------------------------------------------------------
1 | import { Types } from "@meshtastic/core";
2 | import { MessageState, MessageType } from "@core/stores/messageStore/index.ts";
3 |
4 | type NodeNum = number;
5 | type MessageId = number;
6 | type ChannelId = Types.ChannelNumber;
7 | type ConversationId = string;
8 | type MessageLogMap = Map;
9 |
10 | interface MessageBase {
11 | channel: Types.ChannelNumber;
12 | to: number;
13 | from: number;
14 | date: number;
15 | messageId: number;
16 | state: MessageState;
17 | message: string;
18 | }
19 |
20 | interface GenericMessage extends MessageBase {
21 | type: T;
22 | }
23 |
24 | type Message =
25 | | GenericMessage
26 | | GenericMessage;
27 |
28 | type GetMessagesParams =
29 | | { type: MessageType.Direct; nodeA: NodeNum; nodeB: NodeNum }
30 | | { type: MessageType.Broadcast; channelId: ChannelId };
31 |
32 | type SetMessageStateParams =
33 | | {
34 | type: MessageType.Direct;
35 | nodeA: NodeNum;
36 | nodeB: NodeNum;
37 | messageId: MessageId; // ID of the message within that chat
38 | newState?: MessageState; // Optional new state, defaults to Ack
39 | }
40 | | {
41 | type: MessageType.Broadcast;
42 | channelId: ChannelId;
43 | messageId: MessageId;
44 | newState?: MessageState; // Optional new state, defaults to Ack
45 | };
46 |
47 | type ClearMessageParams =
48 | | {
49 | type: MessageType.Direct;
50 | nodeA: NodeNum;
51 | nodeB: NodeNum;
52 | messageId: MessageId;
53 | }
54 | | {
55 | type: MessageType.Broadcast;
56 | channelId: ChannelId;
57 | messageId: MessageId;
58 | };
59 |
60 | export type {
61 | ChannelId,
62 | ClearMessageParams,
63 | ConversationId,
64 | GetMessagesParams,
65 | Message,
66 | MessageId,
67 | MessageLogMap,
68 | NodeNum,
69 | SetMessageStateParams,
70 | };
71 |
--------------------------------------------------------------------------------
/src/core/stores/sidebarStore.tsx:
--------------------------------------------------------------------------------
1 | import React, { createContext, useContext, useMemo, useState } from "react";
2 |
3 | interface SidebarContextProps {
4 | isCollapsed: boolean;
5 | setIsCollapsed: React.Dispatch>;
6 | toggleSidebar: () => void;
7 | }
8 |
9 | const SidebarContext = createContext(
10 | undefined,
11 | );
12 |
13 | export const SidebarProvider: React.FC<{ children: React.ReactNode }> = (
14 | { children },
15 | ) => {
16 | const [isCollapsed, setIsCollapsed] = useState(false);
17 |
18 | const toggleSidebar = useMemo(() => () => {
19 | setIsCollapsed((prev) => !prev);
20 | }, []);
21 |
22 | const value = useMemo(() => ({
23 | isCollapsed,
24 | setIsCollapsed,
25 | toggleSidebar,
26 | }), [isCollapsed, toggleSidebar]);
27 |
28 | return (
29 |
30 | {children}
31 |
32 | );
33 | };
34 |
35 | export const useSidebar = (): SidebarContextProps => {
36 | const context = useContext(SidebarContext);
37 | if (context === undefined) {
38 | throw new Error("useSidebar must be used within a SidebarProvider");
39 | }
40 | return context;
41 | };
42 |
--------------------------------------------------------------------------------
/src/core/utils/bitwise.ts:
--------------------------------------------------------------------------------
1 | export interface EnumLike {
2 | [key: number]: string | number;
3 | }
4 |
5 | export const bitwiseEncode = (enumValues: number[]): number => {
6 | return enumValues.reduce((acc, curr) => acc | curr, 0);
7 | };
8 |
9 | export const bitwiseDecode = (
10 | value: number,
11 | decodeEnum: EnumLike,
12 | ): number[] => {
13 | const enumValues = Object.keys(decodeEnum).map(Number).filter(Boolean);
14 | return enumValues.map((b) => value & b).filter(Boolean);
15 | };
16 |
--------------------------------------------------------------------------------
/src/core/utils/cn.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
--------------------------------------------------------------------------------
/src/core/utils/debounce.test.ts:
--------------------------------------------------------------------------------
1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2 | import { debounce } from "./debounce.ts";
3 |
4 | describe("debounce", () => {
5 | beforeEach(() => {
6 | vi.useFakeTimers();
7 | });
8 |
9 | afterEach(() => {
10 | vi.restoreAllMocks();
11 | });
12 |
13 | it("delays executing the callback until after wait time has elapsed", () => {
14 | const mockCallback = vi.fn();
15 | const debouncedFunction = debounce(mockCallback, 500);
16 |
17 | debouncedFunction();
18 | expect(mockCallback).not.toHaveBeenCalled();
19 |
20 | vi.advanceTimersByTime(300);
21 | expect(mockCallback).not.toHaveBeenCalled();
22 |
23 | vi.advanceTimersByTime(200);
24 | expect(mockCallback).toHaveBeenCalledTimes(1);
25 | });
26 |
27 | it("only executes the callback once if called multiple times within wait period", () => {
28 | const mockCallback = vi.fn();
29 | const debouncedFunction = debounce(mockCallback, 500);
30 |
31 | debouncedFunction();
32 | debouncedFunction();
33 | debouncedFunction();
34 |
35 | vi.advanceTimersByTime(500);
36 | expect(mockCallback).toHaveBeenCalledTimes(1);
37 | });
38 |
39 | it("resets the timer when called again during wait period", () => {
40 | const mockCallback = vi.fn();
41 | const debouncedFunction = debounce(mockCallback, 500);
42 |
43 | debouncedFunction();
44 |
45 | vi.advanceTimersByTime(300);
46 | debouncedFunction();
47 |
48 | vi.advanceTimersByTime(300);
49 | expect(mockCallback).not.toHaveBeenCalled();
50 |
51 | vi.advanceTimersByTime(200);
52 | expect(mockCallback).toHaveBeenCalledTimes(1);
53 | });
54 |
55 | it("passes arguments to the callback function", () => {
56 | const mockCallback = vi.fn();
57 | const debouncedFunction = debounce(mockCallback, 500);
58 |
59 | debouncedFunction("test", 123);
60 |
61 | vi.advanceTimersByTime(500);
62 | expect(mockCallback).toHaveBeenCalledWith("test", 123);
63 | });
64 | });
65 |
--------------------------------------------------------------------------------
/src/core/utils/debounce.ts:
--------------------------------------------------------------------------------
1 | type Callback = (...args: Args) => void;
2 |
3 | export function debounce(
4 | callback: Callback,
5 | wait: number,
6 | ): Callback {
7 | let timeoutId: ReturnType;
8 |
9 | return (...args: Args) => {
10 | clearTimeout(timeoutId);
11 | timeoutId = setTimeout(() => callback(...args), wait);
12 | };
13 | }
14 |
--------------------------------------------------------------------------------
/src/core/utils/dotPath.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from "vitest";
2 | import { dotPaths } from "./dotPath.ts";
3 |
4 | describe("dotPaths", () => {
5 | it("returns flat keys for a simple object", () => {
6 | const obj = { a: 1, b: 2, c: 3 };
7 | expect(dotPaths(obj)).toEqual(["a", "b", "c"]);
8 | });
9 |
10 | it("returns dot notation keys for nested objects", () => {
11 | const obj = { a: { b: { c: 1 } }, d: 2 };
12 | expect(dotPaths(obj)).toEqual(["a.b.c", "d"]);
13 | });
14 |
15 | it("handles arrays at the root", () => {
16 | const arr = [{ x: 1 }, { y: 2 }];
17 | expect(dotPaths(arr)).toEqual(["0.x", "1.y"]);
18 | });
19 |
20 | it("handles arrays nested in objects", () => {
21 | const obj = { a: [{ b: 1 }, { c: 2 }], d: 3 };
22 | expect(dotPaths(obj)).toEqual(["a.0.b", "a.1.c", "d"]);
23 | });
24 |
25 | it("handles objects nested in arrays", () => {
26 | const arr = [{ a: { b: 1 } }, { c: 2 }];
27 | expect(dotPaths(arr)).toEqual(["0.a.b", "1.c"]);
28 | });
29 |
30 | it("handles primitive values in arrays", () => {
31 | const arr = [1, { a: 2 }, 3];
32 | expect(dotPaths(arr)).toEqual(["0", "1.a", "2"]);
33 | });
34 |
35 | it("handles empty objects and arrays", () => {
36 | expect(dotPaths({})).toEqual([]);
37 | expect(dotPaths([])).toEqual([]);
38 | });
39 |
40 | it("handles mixed nested structures", () => {
41 | const obj = {
42 | a: [
43 | { b: 1, c: [2, 3] },
44 | { d: { e: 4 } },
45 | ],
46 | f: 5,
47 | };
48 | expect(dotPaths(obj)).toEqual([
49 | "a.0.b",
50 | "a.0.c.0",
51 | "a.0.c.1",
52 | "a.1.d.e",
53 | "f",
54 | ]);
55 | });
56 |
57 | it("handles prefix argument", () => {
58 | const obj = { a: { b: 1 } };
59 | expect(dotPaths(obj, "root.")).toEqual(["root.a.b"]);
60 | });
61 |
62 | it("skips null and undefined values", () => {
63 | const obj = { a: null, b: undefined, c: { d: 1 } };
64 | expect(dotPaths(obj)).toEqual(["a", "b", "c.d"]);
65 | });
66 | });
67 |
--------------------------------------------------------------------------------
/src/core/utils/dotPath.ts:
--------------------------------------------------------------------------------
1 | export type DotPath = { [key: string]: unknown } | unknown[];
2 |
3 | export const dotPaths = (
4 | obj: T,
5 | prefix = "",
6 | ): string[] => {
7 | if (Array.isArray(obj)) {
8 | return obj.flatMap((v, i) =>
9 | v && typeof v === "object"
10 | ? dotPaths(v as DotPath, `${prefix}${i}.`)
11 | : [`${prefix}${i}`]
12 | );
13 | }
14 | return Object.entries(obj).flatMap(([k, v]) =>
15 | v && typeof v === "object"
16 | ? dotPaths(v as DotPath, `${prefix}${k}.`)
17 | : [`${prefix}${k}`]
18 | );
19 | };
20 |
--------------------------------------------------------------------------------
/src/core/utils/eventBus.test.ts:
--------------------------------------------------------------------------------
1 | import { beforeEach, describe, expect, it, vi } from "vitest";
2 | import { eventBus } from "@core/utils/eventBus.ts";
3 |
4 | describe("EventBus", () => {
5 | beforeEach(() => {
6 | // Reset event listeners before each test
7 | eventBus.listeners = {};
8 | });
9 |
10 | it("should register an event listener and trigger it on emit", () => {
11 | const mockCallback = vi.fn();
12 |
13 | eventBus.on("dialog:unsafeRoles", mockCallback);
14 | eventBus.emit("dialog:unsafeRoles", { action: "confirm" });
15 |
16 | expect(mockCallback).toHaveBeenCalledWith({ action: "confirm" });
17 | });
18 |
19 | it("should remove an event listener with off", () => {
20 | const mockCallback = vi.fn();
21 |
22 | eventBus.on("dialog:unsafeRoles", mockCallback);
23 | eventBus.off("dialog:unsafeRoles", mockCallback);
24 | eventBus.emit("dialog:unsafeRoles", { action: "dismiss" });
25 |
26 | expect(mockCallback).not.toHaveBeenCalled();
27 | });
28 |
29 | it("should return an unsubscribe function from on", () => {
30 | const mockCallback = vi.fn();
31 | const unsubscribe = eventBus.on("dialog:unsafeRoles", mockCallback);
32 |
33 | unsubscribe();
34 | eventBus.emit("dialog:unsafeRoles", { action: "confirm" });
35 |
36 | expect(mockCallback).not.toHaveBeenCalled();
37 | });
38 |
39 | it("should allow multiple listeners for the same event", () => {
40 | const mockCallback1 = vi.fn();
41 | const mockCallback2 = vi.fn();
42 |
43 | eventBus.on("dialog:unsafeRoles", mockCallback1);
44 | eventBus.on("dialog:unsafeRoles", mockCallback2);
45 | eventBus.emit("dialog:unsafeRoles", { action: "confirm" });
46 |
47 | expect(mockCallback1).toHaveBeenCalledWith({ action: "confirm" });
48 | expect(mockCallback2).toHaveBeenCalledWith({ action: "confirm" });
49 | });
50 |
51 | it("should only remove the specific listener when off is called", () => {
52 | const mockCallback1 = vi.fn();
53 | const mockCallback2 = vi.fn();
54 |
55 | eventBus.on("dialog:unsafeRoles", mockCallback1);
56 | eventBus.on("dialog:unsafeRoles", mockCallback2);
57 | eventBus.off("dialog:unsafeRoles", mockCallback1);
58 | eventBus.emit("dialog:unsafeRoles", { action: "dismiss" });
59 |
60 | expect(mockCallback1).not.toHaveBeenCalled();
61 | expect(mockCallback2).toHaveBeenCalledWith({ action: "dismiss" });
62 | });
63 |
64 | it("should not fail when calling off on a non-existent listener", () => {
65 | const mockCallback = vi.fn();
66 | eventBus.off("dialog:unsafeRoles", mockCallback);
67 | eventBus.emit("dialog:unsafeRoles", { action: "confirm" });
68 |
69 | expect(mockCallback).not.toHaveBeenCalled(); // No error should occur
70 | });
71 | });
72 |
--------------------------------------------------------------------------------
/src/core/utils/eventBus.ts:
--------------------------------------------------------------------------------
1 | export type EventMap = {
2 | "dialog:unsafeRoles": {
3 | action: "confirm" | "dismiss";
4 | };
5 | };
6 |
7 | export type EventName = keyof EventMap;
8 | export type EventCallback = (data: EventMap[T]) => void;
9 |
10 | class EventBus {
11 | private listeners: { [K in EventName]?: Array> } = {};
12 |
13 | public on(
14 | event: T,
15 | callback: EventCallback,
16 | ): () => void {
17 | if (!this.listeners[event]) {
18 | this.listeners[event] = [];
19 | }
20 |
21 | this.listeners[event]?.push(callback);
22 |
23 | return () => {
24 | this.off(event, callback);
25 | };
26 | }
27 |
28 | public off(event: T, callback: EventCallback): void {
29 | if (!this.listeners[event]) return;
30 |
31 | const callbackIndex = this.listeners[event]?.indexOf(callback);
32 | if (callbackIndex !== undefined && callbackIndex > -1) {
33 | this.listeners[event]?.splice(callbackIndex, 1);
34 | }
35 | }
36 |
37 | public emit(event: T, data: EventMap[T]): void {
38 | if (!this.listeners[event]) return;
39 |
40 | this.listeners[event]?.forEach((callback) => {
41 | callback(data);
42 | });
43 | }
44 | }
45 |
46 | export const eventBus = new EventBus();
47 |
--------------------------------------------------------------------------------
/src/core/utils/github.ts:
--------------------------------------------------------------------------------
1 | interface RepoIdentifier {
2 | user: string;
3 | repo: string;
4 | }
5 |
6 | interface GithubIssueUrlOptions extends Partial {
7 | repoUrl?: string;
8 | body?: string;
9 | title?: string;
10 | labels?: string[];
11 | template?: string;
12 | assignee?: string;
13 | projects?: string[];
14 | logs?: string;
15 | version?: number;
16 | }
17 |
18 | type ValidatedOptions = {
19 | repoUrl: string;
20 | } & Omit;
21 |
22 | const VALID_PARAMS = [
23 | "body",
24 | "title",
25 | "labels",
26 | "template",
27 | "assignee",
28 | "projects",
29 | "version",
30 | "logs",
31 | ] as const;
32 |
33 | /**
34 | * Generates a URL for creating a new GitHub issue
35 | * @param options Configuration options for the GitHub issue URL
36 | * @returns A formatted URL string for creating a new GitHub issue
37 | * @throws {Error} If repository information is missing or invalid
38 | * @throws {TypeError} If labels or projects are not arrays when provided
39 | */
40 | export default function newGithubIssueUrl(
41 | options: GithubIssueUrlOptions = {},
42 | ): string {
43 | const validatedOptions = validateOptions(options);
44 | const url = new URL(`${validatedOptions.repoUrl}/issues/new`);
45 |
46 | for (const key of VALID_PARAMS) {
47 | const value = validatedOptions[key];
48 |
49 | if (value === undefined) {
50 | continue;
51 | }
52 |
53 | if ((key === "labels" || key === "projects") && Array.isArray(value)) {
54 | url.searchParams.set(key, value.join(","));
55 | continue;
56 | }
57 |
58 | url.searchParams.set(key, String(value));
59 | }
60 |
61 | return url.toString();
62 | }
63 |
64 | function validateOptions(options: GithubIssueUrlOptions): ValidatedOptions {
65 | const repoUrl = options.repoUrl ??
66 | (options.user && options.repo
67 | ? `https://github.com/${options.user}/${options.repo}`
68 | : undefined);
69 |
70 | if (!repoUrl) {
71 | throw new Error(
72 | "You need to specify either the `repoUrl` option or both the `user` and `repo` options",
73 | );
74 | }
75 |
76 | for (const key of ["labels", "projects"] as const) {
77 | const value = options[key];
78 | if (value !== undefined && !Array.isArray(value)) {
79 | throw new TypeError(`The \`${key}\` option should be an array`);
80 | }
81 | }
82 |
83 | return {
84 | ...options,
85 | repoUrl,
86 | };
87 | }
88 |
--------------------------------------------------------------------------------
/src/core/utils/ip.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from "vitest";
2 | import { convertIntToIpAddress, convertIpAddressToInt } from "./ip.ts";
3 |
4 | describe("IP Address Conversion Functions", () => {
5 | describe("convertIntToIpAddress", () => {
6 | it("converts 0 to 0.0.0.0", () => {
7 | expect(convertIntToIpAddress(0)).toBe("0.0.0.0");
8 | });
9 |
10 | it("converts 16_777_343 to 127.0.0.1", () => {
11 | expect(convertIntToIpAddress(16_777_343)).toBe("127.0.0.1");
12 | });
13 |
14 | it("converts 16_820_416 to 192.168.0.1", () => {
15 | expect(convertIntToIpAddress(16_820_416)).toBe("192.168.0.1");
16 | });
17 |
18 | it("converts 4_294_967_295 to 255.255.255.255", () => {
19 | expect(convertIntToIpAddress(4_294_967_295)).toBe("255.255.255.255");
20 | });
21 | });
22 |
23 | describe("convertIpAddressToInt", () => {
24 | it("converts 0.0.0.0 to 0", () => {
25 | expect(convertIpAddressToInt("0.0.0.0")).toBe(0);
26 | });
27 |
28 | it("converts 127.0.0.1 to 16_777_343", () => {
29 | expect(convertIpAddressToInt("127.0.0.1")).toBe(16_777_343);
30 | });
31 |
32 | it("converts 192.168.0.1 to 16_820_416", () => {
33 | expect(convertIpAddressToInt("192.168.0.1")).toBe(16_820_416);
34 | });
35 |
36 | it("converts 255.255.255.255 to 4_294_967_295", () => {
37 | expect(convertIpAddressToInt("255.255.255.255")).toBe(4_294_967_295);
38 | });
39 |
40 | it("handles non-standard formats", () => {
41 | expect(convertIpAddressToInt("1.2.3.4")).toBe(67_305_985);
42 | });
43 |
44 | it("handles invalid IP addresses gracefully", () => {
45 | expect(convertIpAddressToInt("300.1.2.3")).not.toBeNull();
46 | expect(typeof convertIpAddressToInt("300.1.2.3")).toBe("number");
47 | });
48 | });
49 |
50 | describe("bidirectional conversion", () => {
51 | it("can convert back and forth", () => {
52 | const testIps = [
53 | "0.0.0.0",
54 | "127.0.0.1",
55 | "192.168.1.1",
56 | "10.0.0.1",
57 | "255.255.255.255",
58 | ];
59 |
60 | for (const ip of testIps) {
61 | const int = convertIpAddressToInt(ip);
62 | expect(int).not.toBeNull();
63 | if (int !== null) {
64 | const convertedBack = convertIntToIpAddress(int);
65 | expect(convertedBack).toBe(ip);
66 | }
67 | }
68 | });
69 | });
70 | });
71 |
--------------------------------------------------------------------------------
/src/core/utils/ip.ts:
--------------------------------------------------------------------------------
1 | export function convertIntToIpAddress(int: number): string {
2 | return `${int & 0xff}.${(int >> 8) & 0xff}.${(int >> 16) & 0xff}.${
3 | (int >> 24) & 0xff
4 | }`;
5 | }
6 |
7 | export function convertIpAddressToInt(ip: string): number | undefined {
8 | if (!ip) {
9 | return undefined;
10 | }
11 | return (
12 | ip
13 | .split(".")
14 | .reverse()
15 | .reduce((ipnum, octet) => {
16 | return (ipnum << 8) + Number.parseInt(octet);
17 | }, 0) >>> 0
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/src/core/utils/randId.test.ts:
--------------------------------------------------------------------------------
1 | import { beforeEach, describe, expect, it, vi } from "vitest";
2 | import { randId } from "./randId.ts";
3 |
4 | describe("randId", () => {
5 | beforeEach(() => {
6 | vi.restoreAllMocks();
7 | });
8 |
9 | it("returns a number", () => {
10 | const result = randId();
11 | expect(typeof result).toBe("number");
12 | });
13 |
14 | it("returns an integer", () => {
15 | const result = randId();
16 | expect(Number.isInteger(result)).toBe(true);
17 | });
18 |
19 | it("uses Math.random to generate the number", () => {
20 | const mockRandom = vi.spyOn(Math, "random").mockReturnValue(0.5);
21 | const result = randId();
22 |
23 | expect(mockRandom).toHaveBeenCalled();
24 | expect(result).toBe(Math.floor(0.5 * 1e9));
25 | });
26 |
27 | it("returns a value between 0 and 1e9 (exclusive)", () => {
28 | const result = randId();
29 | expect(result).toBeGreaterThanOrEqual(0);
30 | expect(result).toBeLessThan(1e9);
31 | });
32 |
33 | it("returns different values on subsequent calls", () => {
34 | vi.spyOn(Math, "random").mockRestore();
35 |
36 | const results = new Set();
37 |
38 | for (let i = 0; i < 100; i++) {
39 | results.add(randId());
40 | }
41 |
42 | expect(results.size).toBeGreaterThan(95);
43 | });
44 | });
45 |
--------------------------------------------------------------------------------
/src/core/utils/randId.ts:
--------------------------------------------------------------------------------
1 | export const randId = () => {
2 | return Math.floor(Math.random() * 1e9);
3 | };
4 |
--------------------------------------------------------------------------------
/src/core/utils/string.ts:
--------------------------------------------------------------------------------
1 | interface PluralForms {
2 | one: string;
3 | other: string;
4 | [key: string]: string;
5 | }
6 |
7 | interface FormatOptions {
8 | locale?: string;
9 | pluralRules?: Intl.PluralRulesOptions;
10 | numberFormat?: Intl.NumberFormatOptions;
11 | }
12 |
13 | export function formatQuantity(
14 | value: number,
15 | forms: PluralForms,
16 | options: FormatOptions = {},
17 | ) {
18 | const {
19 | locale = "en-US",
20 | pluralRules: pluralOptions = { type: "cardinal" },
21 | numberFormat: numberOptions = {},
22 | } = options;
23 |
24 | const pluralRules = new Intl.PluralRules(locale, pluralOptions);
25 | const numberFormat = new Intl.NumberFormat(locale, numberOptions);
26 |
27 | const pluralCategory = pluralRules.select(value);
28 | const word = forms[pluralCategory];
29 |
30 | return `${numberFormat.format(value)} ${word}`;
31 | }
32 |
33 | export interface LengthValidationResult {
34 | isValid: boolean;
35 | currentLength: number | null;
36 | }
37 |
38 | export function validateMaxByteLength(
39 | value: string | null | undefined,
40 | maxByteLength: number,
41 | ): LengthValidationResult {
42 | // Ensure maxByteLength is valid
43 | if (
44 | typeof maxByteLength !== "number" || !Number.isInteger(maxByteLength) ||
45 | maxByteLength < 0
46 | ) {
47 | console.warn(
48 | "validateMaxByteLength: maxByteLength must be a non-negative integer.",
49 | );
50 | return { isValid: false, currentLength: null }; // Cannot validate with invalid limit
51 | }
52 |
53 | // Handle null or undefined input values
54 | if (value === null || value === undefined) {
55 | return { isValid: false, currentLength: null };
56 | }
57 |
58 | // Check for TextEncoder availability
59 | if (typeof TextEncoder === "undefined") {
60 | console.error(
61 | "validateMaxByteLength: TextEncoder API is not available in this environment.",
62 | );
63 | return { isValid: false, currentLength: null }; // Cannot determine byte length
64 | }
65 |
66 | try {
67 | // Encode the string to UTF-8 bytes and get the length
68 | const encoder = new TextEncoder();
69 | const currentLength = encoder.encode(value).length;
70 | // Perform the byte length check
71 | const isValid = currentLength <= maxByteLength;
72 |
73 | // Return the result object
74 | return { isValid, currentLength };
75 | } catch (error) {
76 | // Handle potential errors during encoding
77 | console.error("validateMaxByteLength: Error encoding string:", error);
78 | return { isValid: false, currentLength: null }; // Encoding failed
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/src/core/utils/test.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | createMemoryHistory,
3 | createRouter,
4 | Outlet,
5 | RootRoute,
6 | Route,
7 | RouterProvider,
8 | } from "@tanstack/react-router";
9 | import { render as rtlRender, RenderOptions } from "@testing-library/react";
10 | import type { FunctionComponent, ReactElement, ReactNode } from "react";
11 |
12 | // a root route for the test router.
13 | const rootRoute = new RootRoute({
14 | component: () => (
15 | <>
16 |
17 | >
18 | ),
19 | });
20 |
21 | interface CustomRenderOptions extends Omit {
22 | initialEntries?: string[];
23 | ui?: ReactElement;
24 | }
25 |
26 | let currentRouter: ReturnType | null = null;
27 |
28 | /**
29 | * Custom render function for testing components that need TanStack Router context.
30 | * @param ui The main ReactElement to render (your component under test).
31 | * @param options Custom render options including initialEntries for the router.
32 | * @returns An object containing the testing-library render result and the router instance.
33 | */
34 | const customRender = (
35 | ui: ReactElement,
36 | options: CustomRenderOptions = {},
37 | ) => {
38 | const { initialEntries = ["/"], ...renderOptions } = options;
39 |
40 | // A specific route that renders the component under test (ui).
41 | // It defaults to the first path in initialEntries or '/'.
42 | const testComponentRoute = new Route({
43 | getParentRoute: () => rootRoute,
44 | path: initialEntries[0] || "/",
45 | component: () => ui, // The component passed to render will be the element for this route
46 | });
47 |
48 | const routeTree = rootRoute.addChildren([testComponentRoute]);
49 |
50 | const router = createRouter({
51 | history: createMemoryHistory({ initialEntries }),
52 | routeTree,
53 | // You can add default error components or other router options if needed for tests.
54 | // defaultErrorComponent: ({ error }) => Test Error: {error.message}
,
55 | });
56 |
57 | currentRouter = router; // Store the router instance for access in tests
58 |
59 | const Wrapper: FunctionComponent<{ children?: ReactNode }> = (
60 | { children },
61 | ) => {
62 | return (
63 | <>
64 |
65 | {children}
66 | >
67 | );
68 | };
69 |
70 | const renderResult = rtlRender(ui, { wrapper: Wrapper, ...renderOptions });
71 |
72 | return {
73 | ...renderResult,
74 | router,
75 | };
76 | };
77 |
78 | export * from "@testing-library/react";
79 | export { customRender as render };
80 | export const getTestRouter = () => currentRouter;
81 |
--------------------------------------------------------------------------------
/src/core/utils/x25519.ts:
--------------------------------------------------------------------------------
1 | import { x25519 } from "@noble/curves/ed25519";
2 |
3 | export function getX25519PrivateKey(): Uint8Array {
4 | const key = x25519.utils.randomPrivateKey();
5 |
6 | // scalar clamping for curve25519, according to
7 | // https://www.rfc-editor.org/rfc/rfc7748#section-5
8 | key[0] &= 248;
9 | key[31] &= 127;
10 | key[31] |= 64;
11 |
12 | return key;
13 | }
14 |
15 | export function getX25519PublicKey(privateKey: Uint8Array): Uint8Array {
16 | return x25519.getPublicKey(privateKey);
17 | }
18 |
--------------------------------------------------------------------------------
/src/i18n/config.ts:
--------------------------------------------------------------------------------
1 | import i18next from "i18next";
2 | import { initReactI18next } from "react-i18next";
3 | import Backend from "i18next-http-backend";
4 | import LanguageDetector from "i18next-browser-languagedetector";
5 |
6 | export type Lang = { code: string; name: string; flag: string };
7 | export type LangCode = Lang["code"];
8 |
9 | export const supportedLanguages: Lang[] = [
10 | // { code: "de", name: "Deutsch", flag: "🇩🇪" },
11 | { code: "en", name: "English", flag: "🇺🇸" },
12 | // { code: "es", name: "Español", flag: "🇪🇸" },
13 | // { code: "fr", name: "Français", flag: "🇫🇷" },
14 | // { code: "zh", name: "中文", flag: "🇨🇳" },
15 | ];
16 |
17 | i18next
18 | .use(Backend)
19 | .use(initReactI18next)
20 | .use(LanguageDetector)
21 | .init({
22 | backend: {
23 | // this will lazy load resources from the i18n folder
24 | loadPath: "/src/i18n/locales/{{lng}}/{{ns}}.json",
25 | },
26 | react: {
27 | useSuspense: true,
28 | },
29 | detection: {
30 | order: ["navigator", "localStorage"],
31 | },
32 | fallbackLng: {
33 | "en-US": ["en"],
34 | "en-CA": ["en-US", "en"],
35 | "default": ["en"],
36 | },
37 | fallbackNS: ["common", "ui", "dialog"],
38 | debug: import.meta.env.DEV,
39 | supportedLngs: supportedLanguages?.map((lang) => lang.code),
40 | ns: [
41 | "channels",
42 | "commandPalette",
43 | "common",
44 | "deviceConfig",
45 | "moduleConfig",
46 | "dashboard",
47 | "dialog",
48 | "messages",
49 | "nodes",
50 | "ui",
51 | ],
52 | });
53 |
--------------------------------------------------------------------------------
/src/i18n/locales/en/channels.json:
--------------------------------------------------------------------------------
1 | {
2 | "page": {
3 | "sectionLabel": "Channels",
4 | "channelName": "Channel: {{channelName}}",
5 | "broadcastLabel": "Primary",
6 | "channelIndex": "Ch {{index}}"
7 | },
8 | "validation": {
9 | "pskInvalid": "Please enter a valid {{bits}} bit PSK."
10 | },
11 | "settings": {
12 | "label": "Channel Settings",
13 | "description": "Crypto, MQTT & misc settings"
14 | },
15 | "role": {
16 | "label": "Role",
17 | "description": "Device telemetry is sent over PRIMARY. Only one PRIMARY allowed",
18 | "options": {
19 | "primary": "PRIMARY",
20 | "disabled": "DISABLED",
21 | "secondary": "SECONDARY"
22 | }
23 | },
24 | "psk": {
25 | "label": "Pre-Shared Key",
26 | "description": "Supported PSK lengths: 256-bit, 128-bit, 8-bit, Empty (0-bit)",
27 | "generate": "Generate"
28 | },
29 | "name": {
30 | "label": "Name",
31 | "description": "A unique name for the channel <12 bytes, leave blank for default"
32 | },
33 | "uplinkEnabled": {
34 | "label": "Uplink Enabled",
35 | "description": "Send messages from the local mesh to MQTT"
36 | },
37 | "downlinkEnabled": {
38 | "label": "Downlink Enabled",
39 | "description": "Send messages from MQTT to the local mesh"
40 | },
41 | "positionPrecision": {
42 | "label": "Location",
43 | "description": "The precision of the location to share with the channel. Can be disabled.",
44 | "options": {
45 | "none": "Do not share location",
46 | "precise": "Precise Location",
47 | "metric_km23": "Within 23 kilometers",
48 | "metric_km12": "Within 12 kilometers",
49 | "metric_km5_8": "Within 5.8 kilometers",
50 | "metric_km2_9": "Within 2.9 kilometers",
51 | "metric_km1_5": "Within 1.5 kilometers",
52 | "metric_m700": "Within 700 meters",
53 | "metric_m350": "Within 350 meters",
54 | "metric_m200": "Within 200 meters",
55 | "metric_m90": "Within 90 meters",
56 | "metric_m50": "Within 50 meters",
57 | "imperial_mi15": "Within 15 miles",
58 | "imperial_mi7_3": "Within 7.3 miles",
59 | "imperial_mi3_6": "Within 3.6 miles",
60 | "imperial_mi1_8": "Within 1.8 miles",
61 | "imperial_mi0_9": "Within 0.9 miles",
62 | "imperial_mi0_5": "Within 0.5 miles",
63 | "imperial_mi0_2": "Within 0.2 miles",
64 | "imperial_ft600": "Within 600 feet",
65 | "imperial_ft300": "Within 300 feet",
66 | "imperial_ft150": "Within 150 feet"
67 | }
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/src/i18n/locales/en/commandPalette.json:
--------------------------------------------------------------------------------
1 | {
2 | "emptyState": "No results found.",
3 | "page": {
4 | "title": "Command Palette"
5 | },
6 | "pinGroup": {
7 | "label": "Pin command group"
8 | },
9 | "unpinGroup": {
10 | "label": "Unpin command group"
11 | },
12 | "goto": {
13 | "label": "Goto",
14 | "command": {
15 | "messages": "Messages",
16 | "map": "Map",
17 | "config": "Config",
18 | "channels": "Channels",
19 | "nodes": "Nodes"
20 | }
21 | },
22 | "manage": {
23 | "label": "Manage",
24 | "command": {
25 | "switchNode": "Switch Node",
26 | "connectNewNode": "Connect New Node"
27 | }
28 | },
29 | "contextual": {
30 | "label": "Contextual",
31 | "command": {
32 | "qrCode": "QR Code",
33 | "qrGenerator": "Generator",
34 | "qrImport": "Import",
35 | "scheduleShutdown": "Schedule Shutdown",
36 | "scheduleReboot": "Schedule Reboot",
37 | "rebootToOtaMode": "Reboot To OTA Mode",
38 | "resetNodeDb": "Reset Node DB",
39 | "factoryResetDevice": "Factory Reset Device",
40 | "factoryResetConfig": "Factory Reset Config"
41 | }
42 | },
43 | "debug": {
44 | "label": "Debug",
45 | "command": {
46 | "reconfigure": "Reconfigure",
47 | "clearAllStoredMessages": "Clear All Stored Message"
48 | }
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/src/i18n/locales/en/dashboard.json:
--------------------------------------------------------------------------------
1 | {
2 | "dashboard": {
3 | "title": "Connected Devices",
4 | "description": "Manage your connected Meshtastic devices.",
5 | "connectionType_ble": "BLE",
6 | "connectionType_serial": "Serial",
7 | "connectionType_network": "Network",
8 | "noDevicesTitle": "No devices connected",
9 | "noDevicesDescription": "Connect a new device to get started.",
10 | "button_newConnection": "New Connection"
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/src/i18n/locales/en/messages.json:
--------------------------------------------------------------------------------
1 | {
2 | "page": {
3 | "title": "Messages: {{chatName}}"
4 | },
5 | "emptyState": {
6 | "title": "Select a Chat",
7 | "text": "No messages yet."
8 | },
9 | "selectChatPrompt": {
10 | "text": "Select a channel or node to start messaging."
11 | },
12 | "sendMessage": {
13 | "placeholder": "Type your message here...",
14 | "sendButton": "Send"
15 | },
16 | "actionsMenu": {
17 | "addReactionLabel": "Add Reaction",
18 | "replyLabel": "Reply"
19 | },
20 |
21 | "item": {
22 | "status": {
23 | "delivered": {
24 | "label": "Message delivered",
25 | "displayText": "Message delivered"
26 | },
27 | "failed": {
28 | "label": "Message delivery failed",
29 | "displayText": "Delivery failed"
30 | },
31 | "unknown": {
32 | "label": "Message status unknown",
33 | "displayText": "Unknown state"
34 | },
35 | "waiting": {
36 | "ariaLabel": "Sending message",
37 | "displayText": "Waiting for delivery"
38 | }
39 | }
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/i18n/locales/en/nodes.json:
--------------------------------------------------------------------------------
1 | {
2 | "nodeDetail": {
3 | "publicKeyEnabled": {
4 | "label": "Public Key Enabled"
5 | },
6 | "noPublicKey": {
7 | "label": "No Public Key"
8 | },
9 | "directMessage": {
10 | "label": "Direct Message {{shortName}}"
11 | },
12 | "favorite": {
13 | "label": "Favorite"
14 | },
15 | "notFavorite": {
16 | "label": "Not a Favorite"
17 | },
18 | "status": {
19 | "heard": "Heard",
20 | "mqtt": "MQTT"
21 | },
22 | "elevation": {
23 | "label": "Elevation"
24 | },
25 | "channelUtil": {
26 | "label": "Channel Util"
27 | },
28 | "airtimeUtil": {
29 | "label": "Airtime Util"
30 | }
31 | },
32 | "nodesTable": {
33 | "headings": {
34 | "longName": "Long Name",
35 | "connection": "Connection",
36 | "lastHeard": "Last Heard",
37 | "encryption": "Encryption",
38 | "model": "Model",
39 | "macAddress": "MAC Address"
40 | },
41 | "connectionStatus": {
42 | "direct": "Direct",
43 | "away": "away",
44 | "unknown": "-",
45 | "viaMqtt": ", via MQTT"
46 | },
47 | "lastHeardStatus": {
48 | "never": "Never"
49 | }
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import "@app/index.css";
2 | import { enableMapSet } from "immer";
3 | import "maplibre-gl/dist/maplibre-gl.css";
4 | import { StrictMode, Suspense } from "react";
5 | import { createRoot } from "react-dom/client";
6 | import "./i18n/config.ts";
7 | import { createRouter, RouterProvider } from "@tanstack/react-router";
8 | import { routeTree } from "@app/routes.tsx";
9 |
10 | declare module "@tanstack/react-router" {
11 | interface Register {
12 | router: ReturnType;
13 | }
14 | }
15 |
16 | const container = document.getElementById("root") as HTMLElement;
17 | const root = createRoot(container);
18 |
19 | enableMapSet();
20 |
21 | const router = createRouter({
22 | routeTree,
23 | });
24 |
25 | root.render(
26 |
27 |
28 |
29 |
30 | ,
31 | );
32 |
--------------------------------------------------------------------------------
/src/pages/Channels.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Tabs,
3 | TabsContent,
4 | TabsList,
5 | TabsTrigger,
6 | } from "@components/UI/Tabs.tsx";
7 | import { Channel } from "@components/PageComponents/Channel.tsx";
8 | import { PageLayout } from "@components/PageLayout.tsx";
9 | import { Sidebar } from "@components/Sidebar.tsx";
10 | import { useDevice } from "@core/stores/deviceStore.ts";
11 | import { Types } from "@meshtastic/core";
12 | import type { Protobuf } from "@meshtastic/core";
13 | import i18next from "i18next";
14 | import { QrCodeIcon, UploadIcon } from "lucide-react";
15 | import { useState } from "react";
16 | import { useTranslation } from "react-i18next";
17 |
18 | export const getChannelName = (channel: Protobuf.Channel.Channel) => {
19 | return channel.settings?.name.length
20 | ? channel.settings?.name
21 | : channel.index === 0
22 | ? i18next.t("page.broadcastLabel")
23 | : i18next.t("page.channelIndex", { ns: "channels", index: channel.index });
24 | };
25 |
26 | const ChannelsPage = () => {
27 | const { t } = useTranslation("channels");
28 | const { channels, setDialogOpen } = useDevice();
29 | const [activeChannel] = useState(
30 | Types.ChannelNumber.Primary,
31 | );
32 |
33 | const currentChannel = channels.get(activeChannel);
34 | const allChannels = Array.from(channels.values());
35 |
36 | return (
37 | <>
38 | }
41 | label={currentChannel
42 | ? getChannelName(currentChannel)
43 | : t("loading", { ns: "common" })}
44 | actions={[
45 | {
46 | key: "import",
47 | icon: UploadIcon,
48 | onClick() {
49 | setDialogOpen("import", true);
50 | },
51 | },
52 | {
53 | key: "qr",
54 | icon: QrCodeIcon,
55 | onClick() {
56 | setDialogOpen("QR", true);
57 | },
58 | },
59 | ]}
60 | >
61 |
62 |
63 | {allChannels.map((channel) => (
64 |
69 | {getChannelName(channel)}
70 |
71 | ))}
72 |
73 | {allChannels.map((channel) => (
74 |
75 |
76 |
77 | ))}
78 |
79 |
80 | >
81 | );
82 | };
83 | export default ChannelsPage;
84 |
--------------------------------------------------------------------------------
/src/pages/Config/DeviceConfig.tsx:
--------------------------------------------------------------------------------
1 | import { Bluetooth } from "@components/PageComponents/Config/Bluetooth.tsx";
2 | import { Device } from "@components/PageComponents/Config/Device/index.tsx";
3 | import { Display } from "@components/PageComponents/Config/Display.tsx";
4 | import { LoRa } from "@components/PageComponents/Config/LoRa.tsx";
5 | import { Network } from "@components/PageComponents/Config/Network/index.tsx";
6 | import { Position } from "@components/PageComponents/Config/Position.tsx";
7 | import { Power } from "@components/PageComponents/Config/Power.tsx";
8 | import { Security } from "@components/PageComponents/Config/Security/Security.tsx";
9 | import {
10 | Tabs,
11 | TabsContent,
12 | TabsList,
13 | TabsTrigger,
14 | } from "@components/UI/Tabs.tsx";
15 | import { useTranslation } from "react-i18next";
16 |
17 | export const DeviceConfig = () => {
18 | const { t } = useTranslation("deviceConfig");
19 | const tabs = [
20 | {
21 | label: t("page.tabDevice"),
22 | element: Device,
23 | count: 0,
24 | },
25 | {
26 | label: t("page.tabPosition"),
27 | element: Position,
28 | },
29 | {
30 | label: t("page.tabPower"),
31 | element: Power,
32 | },
33 | {
34 | label: t("page.tabNetwork"),
35 | element: Network,
36 | },
37 | {
38 | label: t("page.tabDisplay"),
39 | element: Display,
40 | },
41 | {
42 | label: t("page.tabLora"),
43 | element: LoRa,
44 | },
45 | {
46 | label: t("page.tabBluetooth"),
47 | element: Bluetooth,
48 | },
49 | {
50 | label: t("page.tabSecurity"),
51 | element: Security,
52 | },
53 | ];
54 |
55 | return (
56 |
57 |
58 | {tabs.map((tab) => (
59 |
64 | {tab.label}
65 |
66 | ))}
67 |
68 | {tabs.map((tab) => (
69 |
70 |
71 |
72 | ))}
73 |
74 | );
75 | };
76 |
--------------------------------------------------------------------------------
/src/routeTree.gen.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 |
3 | // @ts-nocheck
4 |
5 | // noinspection JSUnusedGlobalSymbols
6 |
7 | // This file was automatically generated by TanStack Router.
8 | // You should NOT make any changes in this file as it will be overwritten.
9 | // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
10 |
11 | // Import Routes
12 |
13 | import { Route as rootRoute } from './routes/__root'
14 |
15 | // Create/Update Routes
16 |
17 | // Populate the FileRoutesByPath interface
18 |
19 | declare module '@tanstack/react-router' {
20 | interface FileRoutesByPath {}
21 | }
22 |
23 | // Create and export the route tree
24 |
25 | export interface FileRoutesByFullPath {}
26 |
27 | export interface FileRoutesByTo {}
28 |
29 | export interface FileRoutesById {
30 | __root__: typeof rootRoute
31 | }
32 |
33 | export interface FileRouteTypes {
34 | fileRoutesByFullPath: FileRoutesByFullPath
35 | fullPaths: never
36 | fileRoutesByTo: FileRoutesByTo
37 | to: never
38 | id: '__root__'
39 | fileRoutesById: FileRoutesById
40 | }
41 |
42 | export interface RootRouteChildren {}
43 |
44 | const rootRouteChildren: RootRouteChildren = {}
45 |
46 | export const routeTree = rootRoute
47 | ._addFileChildren(rootRouteChildren)
48 | ._addFileTypes()
49 |
50 | /* ROUTE_MANIFEST_START
51 | {
52 | "routes": {
53 | "__root__": {
54 | "filePath": "__root.tsx",
55 | "children": []
56 | }
57 | }
58 | }
59 | ROUTE_MANIFEST_END */
60 |
--------------------------------------------------------------------------------
/src/routes.tsx:
--------------------------------------------------------------------------------
1 | import { createRoute, redirect } from "@tanstack/react-router";
2 | import { Dashboard } from "@pages/Dashboard/index.tsx";
3 | import MessagesPage from "@pages/Messages.tsx";
4 | import MapPage from "@pages/Map/index.tsx";
5 | import ConfigPage from "@pages/Config/index.tsx";
6 | import ChannelsPage from "@pages/Channels.tsx";
7 | import NodesPage from "@pages/Nodes.tsx";
8 | import { createRootRoute } from "@tanstack/react-router";
9 | import { App } from "./App.tsx";
10 | import { DialogManager } from "@components/Dialog/DialogManager.tsx";
11 |
12 | const rootRoute = createRootRoute({
13 | component: App,
14 | });
15 |
16 | const indexRoute = createRoute({
17 | getParentRoute: () => rootRoute,
18 | path: "/",
19 | component: Dashboard,
20 | loader: () => {
21 | // Redirect to the broadcast messages page on initial load
22 | return redirect({ to: `/messages/broadcast/0`, replace: true });
23 | },
24 | });
25 |
26 | const messagesRoute = createRoute({
27 | getParentRoute: () => rootRoute,
28 | path: "/messages",
29 | component: MessagesPage,
30 | });
31 |
32 | const messagesWithParamsRoute = createRoute({
33 | getParentRoute: () => rootRoute,
34 | path: "/messages/$type/$chatId",
35 | component: MessagesPage,
36 | });
37 |
38 | const mapRoute = createRoute({
39 | getParentRoute: () => rootRoute,
40 | path: "/map",
41 | component: MapPage,
42 | });
43 |
44 | const configRoute = createRoute({
45 | getParentRoute: () => rootRoute,
46 | path: "/config",
47 | component: ConfigPage,
48 | });
49 |
50 | const channelsRoute = createRoute({
51 | getParentRoute: () => rootRoute,
52 | path: "/channels",
53 | component: ChannelsPage,
54 | });
55 |
56 | const nodesRoute = createRoute({
57 | getParentRoute: () => rootRoute,
58 | path: "/nodes",
59 | component: NodesPage,
60 | });
61 |
62 | const dialogWithParamsRoute = createRoute({
63 | getParentRoute: () => rootRoute,
64 | path: "/dialog/$dialogId",
65 | component: DialogManager,
66 | });
67 |
68 | export const routeTree = rootRoute.addChildren([
69 | indexRoute,
70 | messagesRoute,
71 | messagesWithParamsRoute,
72 | mapRoute,
73 | configRoute,
74 | channelsRoute,
75 | nodesRoute,
76 | dialogWithParamsRoute,
77 | ]);
78 |
79 | export { rootRoute };
80 |
--------------------------------------------------------------------------------
/src/validation/channel.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod/v4";
2 | import { Protobuf } from "@meshtastic/core";
3 | import { makePskHelpers } from "./pskSchema.ts";
4 | import { validateMaxByteLength } from "@core/utils/string.ts";
5 |
6 | const RoleEnum = z.enum(Protobuf.Channel.Channel_Role);
7 |
8 | const moduleSettingsSchema = z.object({
9 | positionPrecision: z.union([
10 | z.literal(0),
11 | z.coerce.number().int().min(10).max(19),
12 | z.literal(32),
13 | ]),
14 | });
15 |
16 | export function makeChannelSchema(allowedBytes: number) {
17 | const { stringSchema } = makePskHelpers([allowedBytes]);
18 |
19 | const ChannelSettingsSchema = z.object({
20 | channelNum: z.coerce.number().int().min(0).max(7),
21 | psk: stringSchema(false),
22 | name: z.string()
23 | .refine(
24 | (s) => validateMaxByteLength(s, 12).isValid,
25 | { message: "formValidation.tooBig.bytes", params: { maximum: 12 } },
26 | ),
27 | id: z.coerce.number().int(),
28 | uplinkEnabled: z.boolean(),
29 | downlinkEnabled: z.boolean(),
30 | moduleSettings: moduleSettingsSchema,
31 | });
32 |
33 | return z.object({
34 | index: z.coerce.number(),
35 | settings: ChannelSettingsSchema,
36 | role: RoleEnum,
37 | });
38 | }
39 |
--------------------------------------------------------------------------------
/src/validation/config/bluetooth.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod/v4";
2 | import { Protobuf } from "@meshtastic/core";
3 |
4 | const PairingModeEnum = z.enum(
5 | Protobuf.Config.Config_BluetoothConfig_PairingMode,
6 | );
7 |
8 | export const BluetoothValidationSchema = z.object({
9 | enabled: z.boolean(),
10 | mode: PairingModeEnum,
11 | fixedPin: z.coerce.number().int().min(100000).max(999999),
12 | });
13 |
14 | export type BluetoothValidation = z.infer;
15 |
--------------------------------------------------------------------------------
/src/validation/config/device.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod/v4";
2 | import { Protobuf } from "@meshtastic/core";
3 |
4 | const RoleEnum = z.enum(
5 | Protobuf.Config.Config_DeviceConfig_Role,
6 | );
7 | const RebroadcastModeEnum = z.enum(
8 | Protobuf.Config.Config_DeviceConfig_RebroadcastMode,
9 | );
10 |
11 | export const DeviceValidationSchema = z.object({
12 | role: RoleEnum,
13 | serialEnabled: z.boolean(),
14 | buttonGpio: z.coerce.number().int().min(0),
15 | buzzerGpio: z.coerce.number().int().min(0),
16 | rebroadcastMode: RebroadcastModeEnum,
17 | nodeInfoBroadcastSecs: z.coerce.number().int().min(0),
18 | doubleTapAsButtonPress: z.boolean(),
19 | isManaged: z.boolean(),
20 | disableTripleClick: z.boolean(),
21 | ledHeartbeatDisabled: z.boolean(),
22 | tzdef: z.string().max(65),
23 | });
24 |
25 | export type DeviceValidation = z.infer;
26 |
--------------------------------------------------------------------------------
/src/validation/config/display.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod/v4";
2 | import { Protobuf } from "@meshtastic/core";
3 |
4 | const GpsCoordinateEnum = z.enum(
5 | Protobuf.Config.Config_DisplayConfig_GpsCoordinateFormat,
6 | );
7 | const DisplayUnitsEnum = z.enum(
8 | Protobuf.Config.Config_DisplayConfig_DisplayUnits,
9 | );
10 | const OledTypeEnum = z.enum(
11 | Protobuf.Config.Config_DisplayConfig_OledType,
12 | );
13 | const DisplayModeEnum = z.enum(
14 | Protobuf.Config.Config_DisplayConfig_DisplayMode,
15 | );
16 | const CompassOrientationEnum = z.enum(
17 | Protobuf.Config.Config_DisplayConfig_CompassOrientation,
18 | );
19 |
20 | export const DisplayValidationSchema = z.object({
21 | screenOnSecs: z.coerce.number().int().min(0),
22 | gpsFormat: GpsCoordinateEnum,
23 | autoScreenCarouselSecs: z.coerce.number().int().min(0),
24 | compassNorthTop: z.boolean(),
25 | flipScreen: z.boolean(),
26 | units: DisplayUnitsEnum,
27 | oled: OledTypeEnum,
28 | displaymode: DisplayModeEnum,
29 | headingBold: z.boolean(),
30 | wakeOnTapOrMotion: z.boolean(),
31 | compassOrientation: CompassOrientationEnum,
32 | use12hClock: z.boolean(),
33 | });
34 |
35 | export type DisplayValidation = z.infer;
36 |
--------------------------------------------------------------------------------
/src/validation/config/lora.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod/v4";
2 | import { Protobuf } from "@meshtastic/core";
3 |
4 | const ModemPresetEnum = z.enum(
5 | Protobuf.Config.Config_LoRaConfig_ModemPreset,
6 | );
7 | const RegionCodeEnum = z.enum(
8 | Protobuf.Config.Config_LoRaConfig_RegionCode,
9 | );
10 |
11 | export const LoRaValidationSchema = z.object({
12 | usePreset: z.boolean(),
13 | modemPreset: ModemPresetEnum,
14 | bandwidth: z.coerce.number().int(),
15 | spreadFactor: z.coerce.number().int().max(12),
16 | codingRate: z.coerce.number().int().min(0).max(10),
17 | frequencyOffset: z.coerce.number().int(),
18 | region: RegionCodeEnum,
19 | hopLimit: z.coerce.number().int().min(0).max(7),
20 | txEnabled: z.boolean(),
21 | txPower: z.coerce.number().int().min(0),
22 | channelNum: z.coerce.number().int(),
23 | overrideDutyCycle: z.boolean(),
24 | sx126xRxBoostedGain: z.boolean(),
25 | overrideFrequency: z.coerce.number().int(),
26 | ignoreIncoming: z.coerce.number().array(),
27 | ignoreMqtt: z.boolean(),
28 | configOkToMqtt: z.boolean(),
29 | });
30 |
31 | export type LoRaValidation = z.infer;
32 |
--------------------------------------------------------------------------------
/src/validation/config/network.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod/v4";
2 | import { Protobuf } from "@meshtastic/core";
3 |
4 | const AddressModeEnum = z.enum(
5 | Protobuf.Config.Config_NetworkConfig_AddressMode,
6 | );
7 | const ProtocolFlagsEnum = z.enum(
8 | Protobuf.Config.Config_NetworkConfig_ProtocolFlags,
9 | );
10 |
11 | export const NetworkValidationIpV4ConfigSchema = z.object({
12 | ip: z.ipv4(),
13 | gateway: z.ipv4(),
14 | subnet: z.ipv4(),
15 | dns: z.ipv4(),
16 | });
17 |
18 | export const NetworkValidationSchema = z.object({
19 | wifiEnabled: z.boolean(),
20 | wifiSsid: z.string().max(33),
21 | wifiPsk: z.string().max(64),
22 | ntpServer: z.string().min(2).max(33),
23 | ethEnabled: z.boolean(),
24 | addressMode: AddressModeEnum,
25 | ipv4Config: NetworkValidationIpV4ConfigSchema,
26 | enabledProtocols: ProtocolFlagsEnum,
27 | rsyslogServer: z.string().max(33),
28 | });
29 |
30 | export type NetworkValidation = z.infer;
31 |
--------------------------------------------------------------------------------
/src/validation/config/position.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod/v4";
2 | import { Protobuf } from "@meshtastic/core";
3 |
4 | const GpsModeEnum = z.enum(
5 | Protobuf.Config.Config_PositionConfig_GpsMode,
6 | );
7 |
8 | export const PositionValidationSchema = z.object({
9 | positionBroadcastSecs: z.coerce.number().int().min(0),
10 | positionBroadcastSmartEnabled: z.boolean(),
11 | fixedPosition: z.boolean(),
12 | gpsUpdateInterval: z.coerce.number().int().min(0),
13 | positionFlags: z.coerce.number().int().min(0),
14 | rxGpio: z.coerce.number().int().min(0),
15 | txGpio: z.coerce.number().int().min(0),
16 | broadcastSmartMinimumDistance: z.coerce.number().int().min(0),
17 | broadcastSmartMinimumIntervalSecs: z.coerce.number().int().min(0),
18 | gpsEnGpio: z.coerce.number().int().min(0),
19 | gpsMode: GpsModeEnum,
20 | });
21 |
22 | export type PositionValidation = z.infer;
23 |
--------------------------------------------------------------------------------
/src/validation/config/power.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod/v4";
2 |
3 | export const PowerValidationSchema = z.object({
4 | isPowerSaving: z.boolean(),
5 | onBatteryShutdownAfterSecs: z.coerce.number().int().min(0),
6 | adcMultiplierOverride: z.coerce.number().min(0).max(4),
7 | waitBluetoothSecs: z.coerce.number().int().min(0),
8 | sdsSecs: z.coerce.number().int().min(0),
9 | lsSecs: z.coerce.number().int().min(0),
10 | minWakeSecs: z.coerce.number().int().min(0),
11 | deviceBatteryInaAddress: z.coerce.number().int().min(0),
12 | });
13 |
14 | export type PowerValidation = z.infer;
15 |
--------------------------------------------------------------------------------
/src/validation/config/security.ts:
--------------------------------------------------------------------------------
1 | import { z, ZodType } from "zod/v4";
2 | import { makePskHelpers } from "./../pskSchema.ts";
3 |
4 | const {
5 | stringSchema,
6 | bytesSchema,
7 | isValidKey,
8 | } = makePskHelpers([32]); // 256-bit
9 |
10 | const isManagedRequiredMsg = "formValidation.adminKeyRequiredWhenManaged";
11 |
12 | function makeSecuritySchema(
13 | keyMaker: (optional: boolean) => ZodType,
14 | ) {
15 | return z
16 | .object({
17 | isManaged: z.boolean(),
18 | adminChannelEnabled: z.boolean(),
19 | debugLogApiEnabled: z.boolean(),
20 | serialEnabled: z.boolean(),
21 |
22 | privateKey: keyMaker(false),
23 | publicKey: keyMaker(false),
24 | adminKey: z.tuple([keyMaker(true), keyMaker(true), keyMaker(true)]),
25 | })
26 | .check((ctx) => {
27 | if (ctx.value.isManaged) {
28 | const hasAdmin = ctx.value.adminKey.some(isValidKey);
29 | if (!hasAdmin) {
30 | for (const path of [["isManaged"], ["adminKey", 0]] as const) {
31 | ctx.issues.push({
32 | code: "custom",
33 | message: isManagedRequiredMsg,
34 | path: [...path],
35 | input: ctx.value,
36 | });
37 | }
38 | }
39 | }
40 | });
41 | }
42 |
43 | export const RawSecuritySchema = makeSecuritySchema(stringSchema);
44 | export type RawSecurity = z.infer;
45 |
46 | export const ParsedSecuritySchema = makeSecuritySchema(bytesSchema);
47 | export type ParsedSecurity = z.infer;
48 |
--------------------------------------------------------------------------------
/src/validation/moduleConfig/ambientLighting.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod/v4";
2 |
3 | export const AmbientLightingValidationSchema = z.object({
4 | ledState: z.boolean(),
5 | current: z.coerce.number().int().min(0),
6 | red: z.coerce.number().int().min(0).max(255),
7 | green: z.coerce.number().int().min(0).max(255),
8 | blue: z.coerce.number().int().min(0).max(255),
9 | });
10 |
11 | export type AmbientLightingValidation = z.infer<
12 | typeof AmbientLightingValidationSchema
13 | >;
14 |
--------------------------------------------------------------------------------
/src/validation/moduleConfig/audio.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod/v4";
2 | import { Protobuf } from "@meshtastic/core";
3 |
4 | const Audio_BaudEnum = z.enum(
5 | Protobuf.ModuleConfig.ModuleConfig_AudioConfig_Audio_Baud,
6 | );
7 |
8 | export const AudioValidationSchema = z.object({
9 | codec2Enabled: z.boolean(),
10 | pttPin: z.coerce.number().int().min(0),
11 | bitrate: Audio_BaudEnum,
12 | i2sWs: z.coerce.number().int().min(0),
13 | i2sSd: z.coerce.number().int().min(0),
14 | i2sDin: z.coerce.number().int().min(0),
15 | i2sSck: z.coerce.number().int().min(0),
16 | });
17 |
18 | export type AudioValidation = z.infer;
19 |
--------------------------------------------------------------------------------
/src/validation/moduleConfig/cannedMessage.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod/v4";
2 | import { Protobuf } from "@meshtastic/core";
3 |
4 | const InputEventCharEnum = z.enum(
5 | Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig_InputEventChar,
6 | );
7 |
8 | export const CannedMessageValidationSchema = z.object({
9 | rotary1Enabled: z.boolean(),
10 | inputbrokerPinA: z.coerce.number().int().min(0),
11 | inputbrokerPinB: z.coerce.number().int().min(0),
12 | inputbrokerPinPress: z.coerce.number().int().min(0),
13 | inputbrokerEventCw: InputEventCharEnum,
14 | inputbrokerEventCcw: InputEventCharEnum,
15 | inputbrokerEventPress: InputEventCharEnum,
16 | updown1Enabled: z.boolean(),
17 | enabled: z.boolean(),
18 | allowInputSource: z.string().max(30),
19 | sendBell: z.boolean(),
20 | });
21 |
22 | export type CannedMessageValidation = z.infer<
23 | typeof CannedMessageValidationSchema
24 | >;
25 |
--------------------------------------------------------------------------------
/src/validation/moduleConfig/detectionSensor.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod/v4";
2 | import { Protobuf } from "@meshtastic/core";
3 |
4 | const detectionTriggerTypeEnum = z.enum(
5 | Protobuf.ModuleConfig.ModuleConfig_DetectionSensorConfig_TriggerType,
6 | );
7 |
8 | export const DetectionSensorValidationSchema = z.object({
9 | enabled: z.boolean(),
10 | minimumBroadcastSecs: z.coerce.number().int().min(0),
11 | stateBroadcastSecs: z.coerce.number().int().min(0),
12 | sendBell: z.boolean(),
13 | name: z.string().min(0).max(20),
14 | monitorPin: z.coerce.number().int().min(0),
15 | detectionTriggerType: detectionTriggerTypeEnum,
16 | usePullup: z.boolean(),
17 | });
18 |
19 | export type DetectionSensorValidation = z.infer<
20 | typeof DetectionSensorValidationSchema
21 | >;
22 |
--------------------------------------------------------------------------------
/src/validation/moduleConfig/externalNotification.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod/v4";
2 |
3 | export const ExternalNotificationValidationSchema = z.object({
4 | enabled: z.boolean(),
5 | outputMs: z.coerce.number().int().min(0),
6 | output: z.coerce.number().int().min(0),
7 | outputVibra: z.coerce.number().int().min(0),
8 | outputBuzzer: z.coerce.number().int().min(0),
9 | active: z.boolean(),
10 | alertMessage: z.boolean(),
11 | alertMessageVibra: z.boolean(),
12 | alertMessageBuzzer: z.boolean(),
13 | alertBell: z.boolean(),
14 | alertBellVibra: z.boolean(),
15 | alertBellBuzzer: z.boolean(),
16 | usePwm: z.boolean(),
17 | nagTimeout: z.coerce.number().int().min(0),
18 | useI2sAsBuzzer: z.boolean(),
19 | });
20 |
21 | export type ExternalNotificationValidation = z.infer<
22 | typeof ExternalNotificationValidationSchema
23 | >;
24 |
--------------------------------------------------------------------------------
/src/validation/moduleConfig/mqtt.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod/v4";
2 |
3 | export const MqttValidationMapReportSettingsSchema = z.object({
4 | publishIntervalSecs: z.number().optional(),
5 | positionPrecision: z.number().optional(),
6 | });
7 |
8 | export const MqttValidationSchema = z.object({
9 | enabled: z.boolean(),
10 | address: z.string().min(0).max(30),
11 | username: z.string().min(0).max(30),
12 | password: z.string().min(0).max(30),
13 | encryptionEnabled: z.boolean(),
14 | jsonEnabled: z.boolean(),
15 | tlsEnabled: z.boolean(),
16 | root: z.string(),
17 | proxyToClientEnabled: z.boolean(),
18 | mapReportingEnabled: z.boolean(),
19 | mapReportSettings: MqttValidationMapReportSettingsSchema,
20 | });
21 |
22 | export type MqttValidation = z.infer;
23 |
--------------------------------------------------------------------------------
/src/validation/moduleConfig/neighborInfo.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod/v4";
2 |
3 | export const NeighborInfoValidationSchema = z.object({
4 | enabled: z.boolean(),
5 | updateInterval: z.coerce.number().int().min(0),
6 | });
7 |
8 | export type NeighborInfoValidation = z.infer<
9 | typeof NeighborInfoValidationSchema
10 | >;
11 |
--------------------------------------------------------------------------------
/src/validation/moduleConfig/paxcounter.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod/v4";
2 |
3 | export const PaxcounterValidationSchema = z.object({
4 | enabled: z.boolean(),
5 | paxcounterUpdateInterval: z.coerce.number().int().min(0),
6 | bleThreshold: z.coerce.number().int(),
7 | wifiThreshold: z.coerce.number().int(),
8 | });
9 |
10 | export type PaxcounterValidation = z.infer;
11 |
--------------------------------------------------------------------------------
/src/validation/moduleConfig/rangeTest.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod/v4";
2 |
3 | export const RangeTestValidationSchema = z.object({
4 | enabled: z.boolean(),
5 | sender: z.coerce.number().int().min(0),
6 | save: z.boolean(),
7 | });
8 |
9 | export type RangeTestValidation = z.infer;
10 |
--------------------------------------------------------------------------------
/src/validation/moduleConfig/serial.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod/v4";
2 | import { Protobuf } from "@meshtastic/core";
3 |
4 | const Serial_BaudEnum = z.enum(
5 | Protobuf.ModuleConfig.ModuleConfig_SerialConfig_Serial_Baud,
6 | );
7 | const Serial_ModeEnum = z.enum(
8 | Protobuf.ModuleConfig.ModuleConfig_SerialConfig_Serial_Mode,
9 | );
10 |
11 | export const SerialValidationSchema = z.object({
12 | enabled: z.boolean(),
13 | echo: z.boolean(),
14 | rxd: z.coerce.number().int().min(0),
15 | txd: z.coerce.number().int().min(0),
16 | baud: Serial_BaudEnum,
17 | timeout: z.coerce.number().int().min(0),
18 | mode: Serial_ModeEnum,
19 | overrideConsoleSerialPort: z.boolean(),
20 | });
21 |
22 | export type SerialValidation = z.infer;
23 |
--------------------------------------------------------------------------------
/src/validation/moduleConfig/storeForward.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod/v4";
2 |
3 | export const StoreForwardValidationSchema = z.object({
4 | enabled: z.boolean(),
5 | heartbeat: z.boolean(),
6 | records: z.coerce.number().int().min(0),
7 | historyReturnMax: z.coerce.number().int().min(0),
8 | historyReturnWindow: z.coerce.number().int().min(0),
9 | });
10 |
11 | export type StoreForwardValidation = z.infer<
12 | typeof StoreForwardValidationSchema
13 | >;
14 |
--------------------------------------------------------------------------------
/src/validation/moduleConfig/telemetry.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod/v4";
2 |
3 | export const TelemetryValidationSchema = z.object({
4 | deviceUpdateInterval: z.coerce.number().int().min(0),
5 | environmentUpdateInterval: z.coerce.number().int().min(0),
6 | environmentMeasurementEnabled: z.boolean(),
7 | environmentScreenEnabled: z.boolean(),
8 | environmentDisplayFahrenheit: z.boolean(),
9 | airQualityEnabled: z.boolean(),
10 | airQualityInterval: z.coerce.number().int().min(0),
11 | powerMeasurementEnabled: z.boolean(),
12 | powerUpdateInterval: z.coerce.number().int().min(0),
13 | powerScreenEnabled: z.boolean(),
14 | });
15 |
16 | export type TelemetryValidation = z.infer<
17 | typeof TelemetryValidationSchema
18 | >;
19 |
--------------------------------------------------------------------------------
/src/validation/pskSchema.ts:
--------------------------------------------------------------------------------
1 | import { z, ZodType } from "zod/v4";
2 | import { toByteArray } from "base64-js";
3 |
4 | export function makePskHelpers(
5 | allowedByteLengths: readonly number[],
6 | ) {
7 | const bitsLabel = allowedByteLengths.map((b) => b * 8).join(" | ");
8 | const msgs = {
9 | format: "formValidation.invalidFormat.key",
10 | required: "formValidation.required.key",
11 | length: `formValidation.pskLength.${bitsLabel.replace(/ \| /g, "_")}bit`,
12 | } as const;
13 |
14 | function tryParse(str: string): Uint8Array | null {
15 | try {
16 | return toByteArray(str);
17 | } catch {
18 | return null;
19 | }
20 | }
21 |
22 | function isValidString(str: string): boolean {
23 | const arr = tryParse(str);
24 | return arr !== null &&
25 | allowedByteLengths.includes(arr.byteLength);
26 | }
27 |
28 | function isValidKey(v: unknown): boolean {
29 | if (typeof v === "string") return isValidString(v);
30 | if (v instanceof Uint8Array) {
31 | return allowedByteLengths.includes(v.byteLength);
32 | }
33 | return false;
34 | }
35 |
36 | const stringSchema = (optional = false) =>
37 | z.string()
38 | .refine((s) =>
39 | optional || s !== "" || (s === "" && allowedByteLengths.includes(0)), {
40 | message: msgs.required,
41 | })
42 | .refine((s) =>
43 | s === "" || tryParse(s) !== null, { message: msgs.format })
44 | .refine((s) =>
45 | s === "" || isValidString(s), {
46 | message: msgs.length,
47 | params: { bits: bitsLabel },
48 | });
49 |
50 | const bytesSchema = (optional = false): ZodType =>
51 | z.instanceof(Uint8Array)
52 | .refine(
53 | (arr) =>
54 | optional || arr.byteLength !== 0 || allowedByteLengths.includes(0),
55 | { message: msgs.required },
56 | )
57 | .refine(
58 | (arr) => optional || allowedByteLengths.includes(arr.byteLength),
59 | { message: msgs.length, params: { bits: bitsLabel } },
60 | );
61 |
62 | return {
63 | allowedByteLengths,
64 | msgs,
65 | tryParseStringKey: tryParse,
66 | isValidKey,
67 | stringSchema,
68 | bytesSchema,
69 | };
70 | }
71 |
--------------------------------------------------------------------------------
/src/validation/validate.ts:
--------------------------------------------------------------------------------
1 | import { ZodError, ZodType } from "zod/v4";
2 |
3 | export function validateSchema(
4 | schema: ZodType,
5 | data: unknown,
6 | ): { success: true; data: T } | { success: false; errors: ZodError["issues"] } {
7 | const result = schema.safeParse(data);
8 | if (result.success) {
9 | return { success: true, data: result.data };
10 | } else {
11 | return { success: false, errors: result.error.issues };
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "github": { "silent": true },
3 | "headers": [
4 | {
5 | "source": "/",
6 | "headers": [
7 | {
8 | "key": "Cross-Origin-Embedder-Policy",
9 | "value": "require-corp"
10 | },
11 | {
12 | "key": "Cross-Origin-Opener-Policy",
13 | "value": "same-origin"
14 | }
15 | ]
16 | }
17 | ]
18 | }
19 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vite";
2 | import react from "@vitejs/plugin-react";
3 | import { VitePWA } from "vite-plugin-pwa";
4 | import { viteStaticCopy } from "vite-plugin-static-copy";
5 | import { execSync } from "node:child_process";
6 | import process from "node:process";
7 | import path from "node:path";
8 |
9 | let hash = "";
10 | try {
11 | hash = execSync("git rev-parse --short HEAD", { encoding: "utf8" }).trim();
12 | } catch (error) {
13 | console.error("Error getting git hash:", error);
14 | hash = "DEV";
15 | }
16 |
17 | export default defineConfig({
18 | plugins: [
19 | react(),
20 | VitePWA({
21 | registerType: "autoUpdate",
22 | strategies: "generateSW",
23 | devOptions: {
24 | enabled: false,
25 | },
26 | workbox: {
27 | cleanupOutdatedCaches: true,
28 | sourcemap: true,
29 | },
30 | }),
31 | viteStaticCopy({
32 | targets: [
33 | {
34 | src: "src/i18n/locales/**/*",
35 | dest: "src/i18n/locales"
36 | }
37 | ]
38 | }),
39 | ],
40 | define: {
41 | "import.meta.env.VITE_COMMIT_HASH": JSON.stringify(hash),
42 | },
43 | build: {
44 | emptyOutDir: true,
45 | assetsDir: "./",
46 | },
47 | resolve: {
48 | alias: {
49 | "@app": path.resolve(process.cwd(), "./src"),
50 | "@pages": path.resolve(process.cwd(), "./src/pages"),
51 | "@components": path.resolve(process.cwd(), "./src/components"),
52 | "@core": path.resolve(process.cwd(), "./src/core"),
53 | "@layouts": path.resolve(process.cwd(), "./src/layouts"),
54 | },
55 | },
56 | server: {
57 | port: 3000,
58 | headers: {
59 | "Cross-Origin-Opener-Policy": "same-origin",
60 | "Cross-Origin-Embedder-Policy": "require-corp",
61 | },
62 | },
63 | });
64 |
--------------------------------------------------------------------------------
/vitest.config.ts:
--------------------------------------------------------------------------------
1 | import path from "node:path";
2 | import react from "@vitejs/plugin-react";
3 | import { defineConfig } from "vitest/config";
4 |
5 | import { enableMapSet } from "immer";
6 | enableMapSet();
7 | export default defineConfig({
8 | plugins: [
9 | react(),
10 | ],
11 | resolve: {
12 | alias: {
13 | "@app": path.resolve(process.cwd(), "./src"),
14 | "@core": path.resolve(process.cwd(), "./src/core"),
15 | "@pages": path.resolve(process.cwd(), "./src/pages"),
16 | "@components": path.resolve(process.cwd(), "./src/components"),
17 | "@layouts": path.resolve(process.cwd(), "./src/layouts"),
18 | },
19 | },
20 | test: {
21 | environment: "happy-dom",
22 | globals: true,
23 | mockReset: true,
24 | clearMocks: true,
25 | restoreMocks: true,
26 | root: path.resolve(process.cwd(), "./src"),
27 | include: ["**/*.{test,spec}.{ts,tsx}"],
28 | setupFiles: ["./src/tests/setupTests.ts", "./src/core/utils/test.tsx"],
29 | },
30 | });
31 |
--------------------------------------------------------------------------------