Compare commits
168 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2f03aaf81 | ||
|
|
6c676fa8da | ||
|
|
183b3ae8cc | ||
|
|
43143bfdf6 | ||
|
|
c6a564f7e4 | ||
|
|
762aed50b7 | ||
|
|
bb91350ef5 | ||
|
|
ec9b299b37 | ||
|
|
0e6a4818ea | ||
|
|
181c984b27 | ||
|
|
7adbe01723 | ||
|
|
a5339af0dd | ||
|
|
c91e5e6b7b | ||
|
|
ccc4202aa4 | ||
|
|
118f848308 | ||
|
|
c36ff60778 | ||
|
|
474e610c3d | ||
|
|
0b6ae0ce32 | ||
|
|
fad1b984bf | ||
|
|
6cc6986904 | ||
|
|
26d5c0a08a | ||
|
|
78e1d1f81a | ||
|
|
48862141dc | ||
|
|
47f8264c31 | ||
|
|
851da0707c | ||
|
|
4275bdd0c0 | ||
|
|
828e5d0903 | ||
|
|
1cbd98ec53 | ||
|
|
df036d3904 | ||
|
|
08dbe94679 | ||
|
|
24e9764fcb | ||
|
|
eb2a2717b1 | ||
|
|
9d74fe2d6e | ||
|
|
8413f6345c | ||
|
|
37a53b747c | ||
|
|
a642080b90 | ||
|
|
9cb5cffdb1 | ||
|
|
57b9942cce | ||
|
|
1274584497 | ||
|
|
513a285fee | ||
|
|
b2bb3baa01 | ||
|
|
3f933dd166 | ||
|
|
1180b9afb0 | ||
|
|
b4ce6efd7b | ||
|
|
da0ada925f | ||
|
|
f9346931f8 | ||
|
|
1d18abf6c1 | ||
|
|
a644d30228 | ||
|
|
1c453e2981 | ||
|
|
170abfcc2f | ||
|
|
0539b15ddc | ||
|
|
bbadb1a917 | ||
|
|
480ca46a95 | ||
|
|
b7bdb1a502 | ||
|
|
3be528d03a | ||
|
|
ebc386cfa5 | ||
|
|
35f3a347ba | ||
|
|
cfcc9f82d8 | ||
|
|
7ab4254cd0 | ||
|
|
e3857e61c6 | ||
|
|
a02579d6dd | ||
|
|
79910dfec7 | ||
|
|
b40540b118 | ||
|
|
99711fc44e | ||
|
|
3eafad7261 | ||
|
|
80905d9d29 | ||
|
|
34db0da87c | ||
|
|
ff33554716 | ||
|
|
f399d17721 | ||
|
|
ce71f22316 | ||
|
|
1f3f76373d | ||
|
|
c5fe2f5e68 | ||
|
|
c050998f3d | ||
|
|
4802a8f6e6 | ||
|
|
03e516e568 | ||
|
|
ef37397969 | ||
|
|
c6d122008b | ||
|
|
3dce031f8e | ||
|
|
91d8776637 | ||
|
|
01f242b7c3 | ||
|
|
d5cf71c840 | ||
|
|
5ba70d9764 | ||
|
|
673476d773 | ||
|
|
5eb9fda015 | ||
|
|
81586caea0 | ||
|
|
a195126df1 | ||
|
|
b3783bab40 | ||
|
|
33d0f93e68 | ||
|
|
2050b05d6a | ||
|
|
38754b9d1a | ||
|
|
1867484032 | ||
|
|
b52ed19649 | ||
|
|
d53ababf7d | ||
|
|
ff43763721 | ||
|
|
c44d7633f2 | ||
|
|
08d641eb42 | ||
|
|
a243a044b9 | ||
|
|
c95a819eaf | ||
|
|
ce5ae675ea | ||
|
|
0828618c0d | ||
|
|
7267101021 | ||
|
|
0e868cef58 | ||
|
|
e410ccb2f4 | ||
|
|
c5b3f2ece6 | ||
|
|
35353c58cb | ||
|
|
e80d8e73ae | ||
|
|
494a35a0c3 | ||
|
|
818bbb4a30 | ||
|
|
4755c0eeb9 | ||
|
|
c8c89fdc95 | ||
|
|
52e0924f1c | ||
|
|
645c758b42 | ||
|
|
4dc7788981 | ||
|
|
9fa945a863 | ||
|
|
38b8695441 | ||
|
|
eadadb5d1d | ||
|
|
5f424e2e0b | ||
|
|
d807cd2de7 | ||
|
|
0b4e3a8da9 | ||
|
|
31be5e9a25 | ||
|
|
367538eeea | ||
|
|
442c1cb5f1 | ||
|
|
a333e4524f | ||
|
|
1e54f7d99b | ||
|
|
9f2aa8282d | ||
|
|
8d5dc440d0 | ||
|
|
8fffde0165 | ||
|
|
1a6e99971a | ||
|
|
4de88c3add | ||
|
|
76374893e3 | ||
|
|
edc17b304a | ||
|
|
ec7b4528f6 | ||
|
|
8d75c4afb1 | ||
|
|
b30fbf90b9 | ||
|
|
8fb95e1b06 | ||
|
|
f5e1a0569f | ||
|
|
929f87b411 | ||
|
|
59d97008f2 | ||
|
|
540b8ebb4d | ||
|
|
109d4afce2 | ||
|
|
aab8bce78e | ||
|
|
d2c33b4caf | ||
|
|
6443544a6b | ||
|
|
a56ac84186 | ||
|
|
443a9ea101 | ||
|
|
0faafe8bc4 | ||
|
|
9948701127 | ||
|
|
ffae92d233 | ||
|
|
74db087d7d | ||
|
|
e00239562c | ||
|
|
8df67bf76a | ||
|
|
80d4670204 | ||
|
|
ed2ab36ed4 | ||
|
|
0d6c5878fc | ||
|
|
dab76df131 | ||
|
|
d1c19d9d3e | ||
|
|
11b052e5bb | ||
|
|
7e1ba42873 | ||
|
|
0bef82ec32 | ||
|
|
f80bb6c42d | ||
|
|
01a74829fc | ||
|
|
7b77b7f5e9 | ||
|
|
ce8fcd2269 | ||
|
|
f6f64eca10 | ||
|
|
c103d7012b | ||
|
|
1f1a3c5de8 | ||
|
|
3bfd96defe | ||
|
|
cad590f993 |
12
.githooks/_/pre-commit
Executable file
@@ -0,0 +1,12 @@
|
||||
#!/bin/sh
|
||||
|
||||
if [ "$SKIP_SIMPLE_GIT_HOOKS" = "1" ]; then
|
||||
echo "[INFO] SKIP_SIMPLE_GIT_HOOKS is set to 1, skipping hook."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -f "$SIMPLE_GIT_HOOKS_RC" ]; then
|
||||
. "$SIMPLE_GIT_HOOKS_RC"
|
||||
fi
|
||||
|
||||
deno task lint:fix && deno task format
|
||||
28
.github/pull_request_template.md
vendored
@@ -1,48 +1,50 @@
|
||||
<!--
|
||||
Thank you for your contribution to our project! Please fill out the following template to help reviewers understand your changes.
|
||||
Thank you for your contribution to our project!
|
||||
-->
|
||||
|
||||
## Description
|
||||
|
||||
<!--
|
||||
Provide a clear and concise description of what this PR does. Explain the problem it solves or the feature it adds.
|
||||
-->
|
||||
|
||||
## Related Issues
|
||||
|
||||
<!--
|
||||
Link any related issues here using the GitHub syntax: "Fixes #123" or "Relates to #456".
|
||||
If there are no related issues, you can remove this section.
|
||||
-->
|
||||
|
||||
## Changes Made
|
||||
|
||||
<!--
|
||||
List the key changes you've made. Focus on the most important aspects that reviewers should understand.
|
||||
-->
|
||||
-
|
||||
-
|
||||
-
|
||||
-
|
||||
|
||||
-
|
||||
-
|
||||
-
|
||||
|
||||
## Testing Done
|
||||
|
||||
<!--
|
||||
Describe how you tested these changes.
|
||||
Describe how you tested these changes (added new tests, etc).
|
||||
-->
|
||||
|
||||
## Screenshots (if applicable)
|
||||
|
||||
<!--
|
||||
If your changes affect the UI, include screenshots or screencasts showing the before and after.
|
||||
-->
|
||||
|
||||
## Checklist
|
||||
|
||||
<!--
|
||||
Check all that apply. If an item doesn't apply to your PR, you can leave it unchecked or remove it.
|
||||
-->
|
||||
|
||||
- [ ] Code follows project style guidelines
|
||||
- [ ] Documentation has been updated or added
|
||||
- [ ] Tests have been added or updated
|
||||
- [ ] All CI checks pass
|
||||
- [ ] Dependent changes have been merged
|
||||
|
||||
## Additional Notes
|
||||
<!--
|
||||
Add any other context about the PR here.
|
||||
-->
|
||||
- [ ] All i18n translation labels have been added (read
|
||||
CONTRIBUTING_I18N_DEVELOPER_GUIDE.md for more details)
|
||||
|
||||
23
.github/workflows/ci.yml
vendored
@@ -1,9 +1,9 @@
|
||||
name: CI
|
||||
name: Push to Main CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -24,6 +24,25 @@ jobs:
|
||||
- name: Install Dependencies
|
||||
run: deno install
|
||||
|
||||
- name: Cache Deno dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cache/deno
|
||||
./deno.lock
|
||||
key: ${{ runner.os }}-deno-${{ hashFiles('**/deno.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-deno-
|
||||
|
||||
- name: Cache Dependencies
|
||||
run: deno cache src/index.tsx
|
||||
|
||||
- name: Run linter
|
||||
run: deno task lint
|
||||
|
||||
- name: Check formatter
|
||||
run: deno task format --check
|
||||
|
||||
- name: Run tests
|
||||
run: deno task test
|
||||
|
||||
|
||||
35
.github/workflows/crowdin-download.yml
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
name: Crowdin Download Translations Action
|
||||
|
||||
on:
|
||||
schedule: # Every Sunday at midnight
|
||||
- cron: '0 0 * * 0'
|
||||
workflow_dispatch: # Allow manual triggering
|
||||
|
||||
jobs:
|
||||
synchronize-with-crowdin:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download translations with Crowdin
|
||||
uses: crowdin/github-action@v2
|
||||
with:
|
||||
base_url: 'https://meshtastic.crowdin.com/api/v2'
|
||||
config: 'crowdin.yml'
|
||||
upload_sources: false
|
||||
upload_translations: false
|
||||
download_translations: true
|
||||
localization_branch_name: i18n_crowdin_translations
|
||||
commit_message: 'chore(i18n): New Crowdin Translations by GitHub Action'
|
||||
create_pull_request: true
|
||||
pull_request_title: 'chore(i18n): New Crowdin Translations'
|
||||
pull_request_body: 'New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)'
|
||||
pull_request_base_branch_name: 'main'
|
||||
pull_request_labels: 'i18n'
|
||||
crowdin_branch_name: 'main'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
32
.github/workflows/crowdin-upload-sources.yml
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
name: Crowdin Upload Sources Action
|
||||
|
||||
on:
|
||||
push:
|
||||
# Monitor all .json files within the /src/i18n/locales/en/ directory.
|
||||
# This ensures the workflow triggers if any the English namespace files are modified on the main branch.
|
||||
paths:
|
||||
- "/src/i18n/locales/en/*.json"
|
||||
branches: [main]
|
||||
workflow_dispatch: # Allow manual triggering
|
||||
|
||||
jobs:
|
||||
synchronize-with-crowdin:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Upload sources with Crowdin
|
||||
uses: crowdin/github-action@v2
|
||||
with:
|
||||
base_url: "https://meshtastic.crowdin.com/api/v2"
|
||||
config: "crowdin.yml"
|
||||
upload_sources: true
|
||||
upload_translations: false
|
||||
download_translations: false
|
||||
crowdin_branch_name: "main"
|
||||
|
||||
env:
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
25
.github/workflows/crowdin-upload-translations.yml
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
name: Crowdin Upload Translations Action
|
||||
|
||||
on:
|
||||
workflow_dispatch: # Allow manual triggering
|
||||
|
||||
jobs:
|
||||
synchronize-with-crowdin:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Upload translations with Crowdin
|
||||
uses: crowdin/github-action@v2
|
||||
with:
|
||||
base_url: "https://meshtastic.crowdin.com/api/v2"
|
||||
config: "crowdin.yml"
|
||||
upload_sources: false
|
||||
upload_translations: true
|
||||
download_translations: false
|
||||
crowdin_branch_name: "main"
|
||||
env:
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
41
.github/workflows/pr.yml
vendored
@@ -1,6 +1,7 @@
|
||||
name: Pull Request
|
||||
name: Pull Request CI
|
||||
|
||||
on: pull_request
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -14,9 +15,43 @@ jobs:
|
||||
with:
|
||||
deno-version: v2.x
|
||||
|
||||
- name: Cache Deno dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cache/deno
|
||||
./deno.lock
|
||||
key: ${{ runner.os }}-deno-${{ hashFiles('**/deno.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-deno-
|
||||
|
||||
- name: Install Dependencies
|
||||
run: deno install
|
||||
|
||||
|
||||
- name: Cache Dependencies
|
||||
run: deno cache src/index.tsx
|
||||
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@v46
|
||||
with:
|
||||
files: |
|
||||
**/*.ts
|
||||
**/*.tsx
|
||||
|
||||
# Uncomment the following lines when you have figured out how to ignore files
|
||||
# - name: Type check changed files
|
||||
# if: steps.changed-files.outputs.all_changed_files != ''
|
||||
# run: deno check ${{ steps.changed-files.outputs.all_changed_files }}
|
||||
|
||||
- name: Run linter on changed files
|
||||
if: steps.changed-files.outputs.all_changed_files != ''
|
||||
run: deno task lint ${{ steps.changed-files.outputs.all_changed_files }}
|
||||
|
||||
- name: Check format on changed files
|
||||
if: steps.changed-files.outputs.all_changed_files != ''
|
||||
run: deno task format --check ${{ steps.changed-files.outputs.all_changed_files }}
|
||||
|
||||
- name: Run tests
|
||||
run: deno task test
|
||||
|
||||
|
||||
10
.github/workflows/release.yml
vendored
@@ -1,8 +1,8 @@
|
||||
name: 'Release'
|
||||
name: Release
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [released]
|
||||
types: [released, prereleased]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -38,6 +38,12 @@ jobs:
|
||||
name: build
|
||||
path: dist/build.tar
|
||||
|
||||
- name: Attach build.tar to release
|
||||
run: |
|
||||
gh release upload ${{ github.event.release.tag_name }} dist/build.tar
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
|
||||
50
.github/workflows/update-stable-from-master.yml
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
name: Update Stable Branch from Main on Latest Release
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [released]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
update-stable-branch:
|
||||
name: Update Stable Branch from Main
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
git config user.name "GitHub Actions Bot"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Fetch latest main and stable branches
|
||||
run: |
|
||||
git fetch origin main:main
|
||||
git fetch origin stable:stable || echo "Stable branch not found remotely, will create."
|
||||
|
||||
- name: Get latest main commit SHA
|
||||
id: get_main_sha
|
||||
run: echo "MAIN_SHA=$(git rev-parse main)" >> $GITHUB_ENV
|
||||
|
||||
- name: Check out stable branch
|
||||
run: |
|
||||
if git show-ref --verify --quiet refs/heads/stable; then
|
||||
git checkout stable
|
||||
git pull origin stable # Sync with remote stable if it exists
|
||||
else
|
||||
echo "Creating local stable branch based on main HEAD."
|
||||
git checkout -b stable ${{ env.MAIN_SHA }}
|
||||
fi
|
||||
|
||||
- name: Reset stable branch to latest main
|
||||
run: git reset --hard ${{ env.MAIN_SHA }}
|
||||
|
||||
- name: Force push stable branch
|
||||
run: git push origin stable --force
|
||||
3
.gitignore
vendored
@@ -4,4 +4,5 @@ stats.html
|
||||
.vercel
|
||||
.vite
|
||||
dev-dist
|
||||
__screenshots__*
|
||||
__screenshots__*
|
||||
*.diff
|
||||
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
[submodule "src/core/connection"]
|
||||
path = src/core/connection
|
||||
url = https://github.com/meshtastic/js.git
|
||||
2
.vscode/extensions.json
vendored
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"recommendations": ["bradlc.vscode-tailwindcss", "biomejs.biome"]
|
||||
"recommendations": ["bradlc.vscode-tailwindcss", "denoland.vscode-deno"]
|
||||
}
|
||||
|
||||
112
CONTRIBUTING_I18N_DEVELOPER_GUIDE.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# i18n Developer Guide
|
||||
|
||||
When developing new components, all user-facing text must be added as an i18n
|
||||
key and rendered using our translation functions. This ensures your UI can be
|
||||
translated into multiple languages.
|
||||
|
||||
## Adding New i18n Keys
|
||||
|
||||
### Search Before Creating
|
||||
|
||||
Before adding a new key, please perform a quick search to see if one that fits
|
||||
your needs already exists. Many common labels like "Save," "Cancel," "Name,"
|
||||
"Description," "Loading...," or "Error" are likely already present, especially
|
||||
in the common.json namespace. Reusing existing keys prevents duplication and
|
||||
ensures consistency across the application. Using your code editor's search
|
||||
function across the /src/i18n/locales/en/ directory is an effective way to do
|
||||
this.
|
||||
|
||||
### Key Naming and Structure Rules
|
||||
|
||||
To maintain consistency and ease of use, please adhere to the following rules
|
||||
when creating new keys in the JSON files.
|
||||
|
||||
- **Keys are camelCase:** `exampleKey`, `anotherExampleKey`.
|
||||
- **Avoid Deep Nesting:** One or two levels of nesting are acceptable for
|
||||
grouping related keys (e.g., all labels for a specific menu). However, nesting
|
||||
deeper than two levels should be avoided to maintain readability and ease of
|
||||
use.
|
||||
- **Good (1 level):**
|
||||
```json
|
||||
"buttons": {
|
||||
"save": "Save",
|
||||
"cancel": "Cancel"
|
||||
}
|
||||
```
|
||||
- **Acceptable (2 levels):**
|
||||
```json
|
||||
"userMenu": {
|
||||
"items": {
|
||||
"profile": "Profile",
|
||||
"settings": "Settings"
|
||||
}
|
||||
}
|
||||
```
|
||||
- **Avoid (3+ levels):**
|
||||
```json
|
||||
"userMenu": {
|
||||
"items": {
|
||||
"actions": {
|
||||
"viewProfile": "View Profile"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
- **Organize for Retrieval, Not UI Layout:** Keys should be named logically for
|
||||
easy retrieval, not to mirror the layout of your component.
|
||||
|
||||
### Namespace Rules
|
||||
|
||||
We use namespaces to organize keys. All source keys are added to the English
|
||||
(`en`) files located at `/src/i18n/locales/en/`. Place your new keys in the
|
||||
appropriate file based on these rules:
|
||||
|
||||
- `common.json`:
|
||||
- All button labels (`save`, `cancel`, `submit`, etc.).
|
||||
- Any text that is repeated and used throughout the application (e.g.,
|
||||
"Loading...", "Error").
|
||||
- `ui.json`:
|
||||
- Labels and text specific to a distinct UI element or view that isn't a
|
||||
dialog or a config page.
|
||||
- `dialog.json`:
|
||||
- All text specific to modal dialogs (titles, body text, prompts).
|
||||
- `messages.json`:
|
||||
- Text specifically related to the messaging interface.
|
||||
- `deviceConfig.json` & `moduleConfig.json`:
|
||||
- Labels and descriptions for the settings on the Device and Module
|
||||
configuration pages.
|
||||
|
||||
## Using i18n Keys in Components
|
||||
|
||||
We use the `useTranslation` hook from `react-i18next` to access the translation
|
||||
function, `t`.
|
||||
|
||||
### Default Namespaces
|
||||
|
||||
Our i18next configuration has fallback namespaces configured which includes
|
||||
`common`, `ui`, and `dialog`. This means you **do not** need to explicitly
|
||||
specify these namespaces when calling the hook. The system will automatically
|
||||
check these files for your key.
|
||||
|
||||
For any keys in `common.json`, `ui.json`, or `dialog.json`, you can instantiate
|
||||
the hook simply:
|
||||
|
||||
```typescript
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
// In your component
|
||||
const { t } = useTranslation(["messages"]);
|
||||
|
||||
// Usage
|
||||
return <p>{t("someMessageLabel")}</p>;
|
||||
```
|
||||
|
||||
You can also specify the namespace on a per-call basis using the options object.
|
||||
This is useful if a component primarily uses a default namespace but needs a
|
||||
single key from another.
|
||||
|
||||
```typescript
|
||||
const { t } = useTranslation();
|
||||
|
||||
return <p>{t("someMessageLabel", { ns: "messages" })}</p>;
|
||||
```
|
||||
31
CONTRIBUTING_TRANSLATIONS.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# Contributing Translations
|
||||
|
||||
Thank you for your interest in making the Meshtastic Web Client accessible to a
|
||||
global audience! Your translation efforts are greatly appreciated.
|
||||
|
||||
## Our Translation Platform: Crowdin
|
||||
|
||||
We manage all our translations through a platform called
|
||||
[Crowdin](https://crowdin.com/). This allows for a collaborative and streamlined
|
||||
translation process. All translation work should be done on our Crowdin project,
|
||||
not directly in the code repository via Pull Requests.
|
||||
|
||||
### How to Get Started
|
||||
|
||||
1. **Create a Crowdin Account:** If you don't already have one, sign up for a
|
||||
free account on Crowdin.
|
||||
2. **Join Our Project:** Please ask for a link to our specific Crowdin project
|
||||
on the Meshtastic Discord.
|
||||
3. **Request Translator Role:** Once you have an account, join the Meshtastic
|
||||
Discord and notify an admin in the `#web` channel. They will grant you the
|
||||
necessary permissions to start translating.
|
||||
4. **Start Translating:** Once you have your role, you can begin translating the
|
||||
source labels into your native language directly on the Crowdin platform.
|
||||
|
||||
### Language Activation
|
||||
|
||||
A new language will only be added to the web client and appear in the language
|
||||
picker once its translation is 100% complete on Crowdin. The repository
|
||||
maintainers will handle this process once the milestone is reached.
|
||||
|
||||
Thank you for helping us bring Meshtastic to more users around the world!
|
||||
23
README.md
@@ -2,7 +2,7 @@
|
||||
|
||||
<!--Project specific badges here-->
|
||||
|
||||
[](https://github.com/meshtastic/web/actions/workflows/ci.yml)
|
||||
[](https://github.com/meshtastic/web/actions/workflows/ci.yml)
|
||||
[](https://cla-assistant.io/meshtastic/web)
|
||||
[](https://opencollective.com/meshtastic/)
|
||||
[](https://vercel.com?utm_source=meshtastic&utm_campaign=oss)
|
||||
@@ -50,11 +50,20 @@ docker run -d -p 8080:8080 --restart always --name Meshtastic-Web ghcr.io/meshta
|
||||
podman run -d -p 8080:8080 --restart always --name Meshtastic-Web ghcr.io/meshtastic/web
|
||||
```
|
||||
|
||||
## Nightly releases
|
||||
## Release Schedule
|
||||
|
||||
Our nightly releases provide the latest development builds with cutting-edge
|
||||
features and fixes. These builds are automatically generated from the latest
|
||||
main branch every night and are available for testing and early adoption.
|
||||
Our release process follows these guidelines:
|
||||
|
||||
- **Versioning:** We use Semantic Versioning (`Major.Minor.Patch`).
|
||||
- **Stable Releases:** Published around the beginning of each month (e.g.,
|
||||
`v2.3.4`).
|
||||
- **Pre-releases:** A pre-release is typically issued mid-month for testing and
|
||||
early adoption.
|
||||
- **Nightly Builds:** An experimental Docker image containing the latest
|
||||
cutting-edge features and fixes is automatically built nightly from the
|
||||
`main` branch.
|
||||
|
||||
### Nightly Builds
|
||||
|
||||
```bash
|
||||
# With Docker
|
||||
@@ -73,7 +82,7 @@ podman run -d -p 8080:8080 --restart always --name Meshtastic-Web ghcr.io/meshta
|
||||
> new features
|
||||
> - No guarantee of backward compatibility between nightly builds
|
||||
|
||||
### Version Information
|
||||
#### Version Information
|
||||
|
||||
Each nightly build is tagged with:
|
||||
|
||||
@@ -161,6 +170,6 @@ requests:
|
||||
Meshtastic nodes.
|
||||
|
||||
Please review our
|
||||
[Contribution Guidelines](https://github.com/meshtastic/web/blob/master/CONTRIBUTING.md)
|
||||
[Contribution Guidelines](https://github.com/meshtastic/web/blob/main/CONTRIBUTING.md)
|
||||
before submitting a pull request. We appreciate your help in making the project
|
||||
better!
|
||||
|
||||
10
crowdin.yml
Normal file
@@ -0,0 +1,10 @@
|
||||
project_id_env: CROWDIN_PROJECT_ID
|
||||
api_token_env: CROWDIN_PERSONAL_TOKEN
|
||||
base_path: "."
|
||||
base_url: "https://meshtastic.crowdin.com/api/v2"
|
||||
|
||||
preserve_hierarchy: true
|
||||
|
||||
files:
|
||||
- source: "/src/i18n/locales/en/*.json"
|
||||
translation: "/src/i18n/locales/%locale%/%original_file_name%"
|
||||
32
deno.json
@@ -4,8 +4,10 @@
|
||||
"@pages/": "./src/pages/",
|
||||
"@components/": "./src/components/",
|
||||
"@core/": "./src/core/",
|
||||
"@layouts/": "./src/layouts/"
|
||||
"@layouts/": "./src/layouts/",
|
||||
"@std/path": "jsr:@std/path@^1.1.0"
|
||||
},
|
||||
"include": ["src", "./vite-env.d.ts"],
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
"DOM",
|
||||
@@ -23,11 +25,35 @@
|
||||
"types": [
|
||||
"vite/client",
|
||||
"node",
|
||||
"@types/web-bluetooth",
|
||||
"@types/w3c-web-serial"
|
||||
"npm:@types/w3c-web-serial",
|
||||
"npm:@types/web-bluetooth"
|
||||
],
|
||||
"strictPropertyInitialization": false
|
||||
},
|
||||
"fmt": {
|
||||
"exclude": [
|
||||
"src/routeTree.gen.ts",
|
||||
"*.test.ts",
|
||||
"*.test.tsx"
|
||||
]
|
||||
},
|
||||
"lint": {
|
||||
"exclude": [
|
||||
"src/routeTree.gen.ts",
|
||||
"*.test.ts",
|
||||
"*.test.tsx"
|
||||
],
|
||||
"report": "pretty"
|
||||
},
|
||||
"exclude": [
|
||||
"routeTree.gen.ts",
|
||||
"node_modules/",
|
||||
"dist",
|
||||
"build",
|
||||
"coverage",
|
||||
"out",
|
||||
".vscode-test"
|
||||
],
|
||||
"unstable": [
|
||||
"sloppy-imports"
|
||||
]
|
||||
|
||||
100
package.json
@@ -11,8 +11,9 @@
|
||||
"lint:fix": "deno lint --fix src/",
|
||||
"format": "deno fmt src/",
|
||||
"dev": "deno task dev:ui",
|
||||
"dev:ui": "deno run -A npm:vite dev",
|
||||
"dev:ui": "VITE_APP_VERSION=development deno run -A npm:vite dev",
|
||||
"test": "deno run -A npm:vitest",
|
||||
"check": "deno check",
|
||||
"preview": "deno run -A npm:vite preview",
|
||||
"package": "gzipper c -i html,js,css,png,ico,svg,webmanifest,txt dist dist/output && tar -cvf dist/build.tar -C ./dist/output/ ."
|
||||
},
|
||||
@@ -34,76 +35,87 @@
|
||||
},
|
||||
"homepage": "https://meshtastic.org",
|
||||
"dependencies": {
|
||||
"@meshtastic/core": "npm:@jsr/meshtastic__core@2.6.2",
|
||||
"@bufbuild/protobuf": "^2.2.5",
|
||||
"@hookform/resolvers": "^5.1.1",
|
||||
"@meshtastic/core": "npm:@jsr/meshtastic__core@2.6.4",
|
||||
"@meshtastic/js": "npm:@jsr/meshtastic__js@2.6.0-0",
|
||||
"@meshtastic/transport-http": "npm:@jsr/meshtastic__transport-http",
|
||||
"@meshtastic/transport-web-bluetooth": "npm:@jsr/meshtastic__transport-web-bluetooth",
|
||||
"@meshtastic/transport-web-serial": "npm:@jsr/meshtastic__transport-web-serial",
|
||||
"@bufbuild/protobuf": "^2.2.5",
|
||||
"@noble/curves": "^1.8.1",
|
||||
"@radix-ui/react-accordion": "^1.2.3",
|
||||
"@radix-ui/react-checkbox": "^1.1.4",
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.6",
|
||||
"@radix-ui/react-label": "^2.1.2",
|
||||
"@radix-ui/react-menubar": "^1.1.6",
|
||||
"@radix-ui/react-popover": "^1.1.6",
|
||||
"@radix-ui/react-scroll-area": "^1.2.3",
|
||||
"@radix-ui/react-select": "^2.1.6",
|
||||
"@radix-ui/react-separator": "^1.1.2",
|
||||
"@radix-ui/react-switch": "^1.1.3",
|
||||
"@radix-ui/react-tabs": "^1.1.3",
|
||||
"@radix-ui/react-toast": "^1.2.6",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"@noble/curves": "^1.9.0",
|
||||
"@radix-ui/react-accordion": "^1.2.8",
|
||||
"@radix-ui/react-checkbox": "^1.2.3",
|
||||
"@radix-ui/react-dialog": "^1.1.11",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.12",
|
||||
"@radix-ui/react-label": "^2.1.4",
|
||||
"@radix-ui/react-menubar": "^1.1.12",
|
||||
"@radix-ui/react-popover": "^1.1.11",
|
||||
"@radix-ui/react-scroll-area": "^1.2.6",
|
||||
"@radix-ui/react-select": "^2.2.2",
|
||||
"@radix-ui/react-separator": "^1.1.4",
|
||||
"@radix-ui/react-slider": "^1.3.2",
|
||||
"@radix-ui/react-switch": "^1.2.2",
|
||||
"@radix-ui/react-tabs": "^1.1.9",
|
||||
"@radix-ui/react-toast": "^1.2.11",
|
||||
"@radix-ui/react-toggle-group": "^1.1.9",
|
||||
"@radix-ui/react-tooltip": "^1.2.4",
|
||||
"@tanstack/react-router": "^1.120.15",
|
||||
"@tanstack/react-router-devtools": "^1.120.16",
|
||||
"@tanstack/router-devtools": "^1.120.15",
|
||||
"@turf/turf": "^7.2.0",
|
||||
"@types/web-bluetooth": "^0.0.21",
|
||||
"base64-js": "^1.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"crypto-random-string": "^5.0.0",
|
||||
"i18next": "^25.2.0",
|
||||
"i18next-browser-languagedetector": "^8.1.0",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"immer": "^10.1.1",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lucide-react": "^0.486.0",
|
||||
"maplibre-gl": "5.3.0",
|
||||
"lucide-react": "^0.507.0",
|
||||
"maplibre-gl": "5.4.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-error-boundary": "^5.0.0",
|
||||
"react-hook-form": "^7.55.0",
|
||||
"react-map-gl": "8.0.2",
|
||||
"react-error-boundary": "^6.0.0",
|
||||
"react-hook-form": "^7.56.2",
|
||||
"react-i18next": "^15.5.1",
|
||||
"react-map-gl": "8.0.4",
|
||||
"react-qrcode-logo": "^3.0.0",
|
||||
"rfc4648": "^1.5.4",
|
||||
"vite-plugin-node-polyfills": "^0.23.0",
|
||||
"zod": "^3.24.2",
|
||||
"zustand": "5.0.3"
|
||||
"zod": "^3.25.67",
|
||||
"zustand": "5.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.0",
|
||||
"@tailwindcss/postcss": "^4.1.5",
|
||||
"@tanstack/router-plugin": "^1.120.15",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/chrome": "^0.0.313",
|
||||
"@types/chrome": "^0.0.318",
|
||||
"@types/node": "^22.15.3",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.3",
|
||||
"@types/serviceworker": "^0.0.133",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/node": "^22.13.17",
|
||||
"@types/react": "^19.0.12",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@types/serviceworker": "^0.0.127",
|
||||
"@types/w3c-web-serial": "^1.0.8",
|
||||
"@types/web-bluetooth": "^0.0.21",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"gzipper": "^8.2.1",
|
||||
"happy-dom": "^17.4.4",
|
||||
"happy-dom": "^17.4.6",
|
||||
"postcss": "^8.5.3",
|
||||
"simple-git-hooks": "^2.12.1",
|
||||
"tailwind-merge": "^3.1.0",
|
||||
"tailwindcss": "^4.1.0",
|
||||
"simple-git-hooks": "^2.13.0",
|
||||
"tailwind-merge": "^3.2.0",
|
||||
"tailwindcss": "^4.1.5",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tar": "^7.4.3",
|
||||
"testing-library": "^0.0.2",
|
||||
"typescript": "^5.8.2",
|
||||
"vite": "^6.2.4",
|
||||
"vitest": "^3.1.1",
|
||||
"vite-plugin-pwa": "^1.0.0"
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.3.4",
|
||||
"vite-plugin-pwa": "^1.0.0",
|
||||
"vite-plugin-static-copy": "^3.0.0",
|
||||
"vitest": "^3.1.2"
|
||||
}
|
||||
}
|
||||
|
||||
16
public/Logo.svg
Normal file
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no" ?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="512" height="512" viewBox="0 0 512 512" xml:space="preserve">
|
||||
<desc>Created with Fabric.js 4.6.0</desc>
|
||||
<defs>
|
||||
</defs>
|
||||
<g transform="matrix(1 0 0 1 256 256)" id="xYQ9Gk9Jwpgj_HMOXB3F_" >
|
||||
<path style="stroke: rgb(213,130,139); stroke-width: 0; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(103,234,148); fill-rule: nonzero; opacity: 1;" vector-effect="non-scaling-stroke" transform=" translate(-256, -256)" d="M 0 0 L 512 0 L 512 512 L 0 512 z" stroke-linecap="round" />
|
||||
</g>
|
||||
<g transform="matrix(1.79 0 0 1.79 313.74 258.36)" id="1xBsk2n9FZp60Rz1O-ceJ" >
|
||||
<path style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: round; stroke-miterlimit: 2; fill: rgb(44,45,60); fill-rule: evenodd; opacity: 1;" vector-effect="non-scaling-stroke" transform=" translate(-250.97, -362.41)" d="M 250.908 330.267 L 193.126 415.005 L 180.938 406.694 L 244.802 313.037 C 246.174 311.024 248.453 309.819 250.889 309.816 C 253.326 309.814 255.606 311.015 256.982 313.026 L 320.994 406.536 L 308.821 414.869 L 250.908 330.267 Z" stroke-linecap="round" />
|
||||
</g>
|
||||
<g transform="matrix(1.81 0 0 1.81 145 256.15)" id="KxN7E9YpbyPgz0S4z4Cl6" >
|
||||
<path style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: round; stroke-miterlimit: 2; fill: rgb(44,45,60); fill-rule: evenodd; opacity: 1;" vector-effect="non-scaling-stroke" transform=" translate(-115.14, -528.06)" d="M 87.642 581.398 L 154.757 482.977 L 142.638 474.713 L 75.523 573.134 L 87.642 581.398 Z" stroke-linecap="round" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
@@ -1,5 +0,0 @@
|
||||
# Copyright Notice
|
||||
Copyright © 2024 Meshtastic LLC. All Rights Reserved.
|
||||
|
||||
## In reference to the GNU GPLv3 License terms defined in Section 7e
|
||||
Images (or assets) in this directory are protected under international copyright laws and treaties. Unauthorized reproduction, distribution, modification, or use of these images in any form, commercial or otherwise, outside of official Meshtastic creative works or its Backers and Partners is strictly prohibited without prior written consent from the copyright holder (Meshtastic LLC).
|
||||
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 9.9 KiB |
|
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 9.9 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 8.7 KiB After Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "Meshtastic",
|
||||
"short_name": "Meshtastic",
|
||||
"short_name": "Web Client",
|
||||
"start_url": ".",
|
||||
"description": "Meshtastic web app",
|
||||
"description": "Meshtastic Web App",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon.svg",
|
||||
"src": "/Logo.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml"
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 8.0 KiB After Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 120 KiB |
60
src/App.tsx
@@ -1,6 +1,4 @@
|
||||
import { DeviceWrapper } from "@app/DeviceWrapper.tsx";
|
||||
import { PageRouter } from "@app/PageRouter.tsx";
|
||||
import { DeviceSelector } from "@components/DeviceSelector.tsx";
|
||||
import { DialogManager } from "@components/Dialog/DialogManager.tsx";
|
||||
import { NewDeviceDialog } from "@components/Dialog/NewDeviceDialog.tsx";
|
||||
import { KeyBackupReminder } from "@components/KeyBackupReminder.tsx";
|
||||
@@ -9,20 +7,25 @@ import Footer from "@components/UI/Footer.tsx";
|
||||
import { useAppStore } from "@core/stores/appStore.ts";
|
||||
import { useDeviceStore } from "@core/stores/deviceStore.ts";
|
||||
import { Dashboard } from "@pages/Dashboard/index.tsx";
|
||||
import type { JSX } from "react";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import { ErrorPage } from "@components/UI/ErrorPage.tsx";
|
||||
import { MapProvider } from "react-map-gl/maplibre";
|
||||
import { CommandPalette } from "@components/CommandPalette/index.tsx";
|
||||
import { SidebarProvider } from "@core/stores/sidebarStore.tsx";
|
||||
import { useTheme } from "@core/hooks/useTheme.ts";
|
||||
import { Outlet } from "@tanstack/react-router";
|
||||
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
|
||||
|
||||
|
||||
export const App = (): JSX.Element => {
|
||||
export function App() {
|
||||
const { getDevice } = useDeviceStore();
|
||||
const { selectedDevice, setConnectDialogOpen, connectDialogOpen } =
|
||||
useAppStore();
|
||||
|
||||
const device = getDevice(selectedDevice);
|
||||
|
||||
// Sets up light/dark mode based on user preferences or system settings
|
||||
useTheme();
|
||||
|
||||
return (
|
||||
<ErrorBoundary FallbackComponent={ErrorPage}>
|
||||
<NewDeviceDialog
|
||||
@@ -32,30 +35,35 @@ export const App = (): JSX.Element => {
|
||||
}}
|
||||
/>
|
||||
<Toaster />
|
||||
<TanStackRouterDevtools position="bottom-right" />
|
||||
<DeviceWrapper device={device}>
|
||||
<div className="flex h-screen flex-col overflow-hidden bg-background-primary text-text-primary">
|
||||
<div className="flex grow">
|
||||
<DeviceSelector />
|
||||
<div className="flex grow flex-col">
|
||||
{device ? (
|
||||
<div className="flex h-screen w-full">
|
||||
<DialogManager />
|
||||
<KeyBackupReminder />
|
||||
<CommandPalette />
|
||||
<MapProvider>
|
||||
<PageRouter />
|
||||
</MapProvider>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Dashboard />
|
||||
<Footer />
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
className="flex h-screen flex-col bg-background-primary text-text-primary"
|
||||
style={{ scrollbarWidth: "thin" }}
|
||||
>
|
||||
<SidebarProvider>
|
||||
<div className="h-full flex flex-col">
|
||||
{device
|
||||
? (
|
||||
<div className="h-full flex w-full">
|
||||
<DialogManager />
|
||||
<KeyBackupReminder />
|
||||
<CommandPalette />
|
||||
<MapProvider>
|
||||
<Outlet />
|
||||
</MapProvider>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<Dashboard />
|
||||
<Footer />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
</div>
|
||||
</DeviceWrapper>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import MapPage from "@app/pages/Map/index.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import ChannelsPage from "@pages/Channels.tsx";
|
||||
import ConfigPage from "@pages/Config/index.tsx";
|
||||
import MessagesPage from "@pages/Messages.tsx";
|
||||
import NodesPage from "@pages/Nodes.tsx";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import { ErrorPage } from "@components/UI/ErrorPage.tsx";
|
||||
|
||||
export const ErrorBoundaryWrapper = ({
|
||||
children,
|
||||
}: { children: React.ReactNode }) => (
|
||||
<ErrorBoundary FallbackComponent={ErrorPage}>{children}</ErrorBoundary>
|
||||
);
|
||||
|
||||
export const PageRouter = () => {
|
||||
const { activePage } = useDevice();
|
||||
return (
|
||||
<ErrorBoundary FallbackComponent={ErrorPage}>
|
||||
{activePage === "messages" && <MessagesPage />}
|
||||
{activePage === "map" && <MapPage />}
|
||||
{activePage === "config" && <ConfigPage />}
|
||||
{activePage === "channels" && <ChannelsPage />}
|
||||
{activePage === "nodes" && <NodesPage />}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
@@ -1,13 +1,13 @@
|
||||
import { vi } from 'vitest'
|
||||
import { vi } from "vitest";
|
||||
|
||||
vi.mock('@components/UI/Button.tsx', () => ({
|
||||
vi.mock("@components/UI/Button.tsx", () => ({
|
||||
Button: ({ children, name, disabled, onClick }: {
|
||||
children: React.ReactNode,
|
||||
variant: string,
|
||||
name: string,
|
||||
disabled?: boolean,
|
||||
onClick: () => void
|
||||
}) =>
|
||||
children: React.ReactNode;
|
||||
variant: string;
|
||||
name: string;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
name={name}
|
||||
@@ -17,4 +17,5 @@ vi.mock('@components/UI/Button.tsx', () => ({
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
}));
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
import { vi } from 'vitest'
|
||||
import { vi } from "vitest";
|
||||
|
||||
vi.mock('@components/UI/Checkbox.tsx', () => ({
|
||||
Checkbox: ({ id, checked, onChange }: { id: string, checked: boolean, onChange: () => void }) =>
|
||||
<input data-testid="checkbox" type="checkbox" id={id} checked={checked} onChange={onChange} />
|
||||
}));
|
||||
vi.mock("@components/UI/Checkbox.tsx", () => ({
|
||||
Checkbox: (
|
||||
{ id, checked, onChange }: {
|
||||
id: string;
|
||||
checked: boolean;
|
||||
onChange: () => void;
|
||||
},
|
||||
) => (
|
||||
<input
|
||||
data-testid="checkbox"
|
||||
type="checkbox"
|
||||
id={id}
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -1,43 +1,45 @@
|
||||
import React from 'react';
|
||||
import React from "react";
|
||||
|
||||
export const Dialog = ({ children, open }: {
|
||||
children: React.ReactNode,
|
||||
open: boolean,
|
||||
onOpenChange?: (open: boolean) => void
|
||||
children: React.ReactNode;
|
||||
open: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}) => open ? <div data-testid="dialog">{children}</div> : null;
|
||||
|
||||
export const DialogContent = ({
|
||||
children,
|
||||
className
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode,
|
||||
className?: string
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) => <div data-testid="dialog-content" className={className}>{children}</div>;
|
||||
|
||||
export const DialogHeader = ({
|
||||
children
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
children: React.ReactNode;
|
||||
}) => <div data-testid="dialog-header">{children}</div>;
|
||||
|
||||
export const DialogTitle = ({
|
||||
children
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
children: React.ReactNode;
|
||||
}) => <div data-testid="dialog-title">{children}</div>;
|
||||
|
||||
export const DialogDescription = ({
|
||||
children,
|
||||
className
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode,
|
||||
className?: string
|
||||
}) => <div data-testid="dialog-description" className={className}>{children}</div>;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) => (
|
||||
<div data-testid="dialog-description" className={className}>{children}</div>
|
||||
);
|
||||
|
||||
export const DialogFooter = ({
|
||||
children,
|
||||
className
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode,
|
||||
className?: string
|
||||
}) => <div data-testid="dialog-footer" className={className}>{children}</div>;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) => <div data-testid="dialog-footer" className={className}>{children}</div>;
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { vi } from 'vitest'
|
||||
import { vi } from "vitest";
|
||||
|
||||
vi.mock('@components/UI/Label.tsx', () => ({
|
||||
Label: ({ children, htmlFor, className }: { children: React.ReactNode, htmlFor: string, className?: string }) =>
|
||||
<label data-testid="label" htmlFor={htmlFor} className={className}>{children}</label>
|
||||
}));
|
||||
vi.mock("@components/UI/Label.tsx", () => ({
|
||||
Label: (
|
||||
{ children, htmlFor, className }: {
|
||||
children: React.ReactNode;
|
||||
htmlFor: string;
|
||||
className?: string;
|
||||
},
|
||||
) => (
|
||||
<label data-testid="label" htmlFor={htmlFor} className={className}>
|
||||
{children}
|
||||
</label>
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
vi.mock('@components/UI/Typography/Link.tsx', () => ({
|
||||
Link: ({ children, href, className }: { children: React.ReactNode, href: string, className?: string }) =>
|
||||
<a data-testid="link" href={href} className={className}>{children}</a>
|
||||
vi.mock("@components/UI/Typography/Link.tsx", () => ({
|
||||
Link: (
|
||||
{ children, href, className }: {
|
||||
children: React.ReactNode;
|
||||
href: string;
|
||||
className?: string;
|
||||
},
|
||||
) => <a data-testid="link" href={href} className={className}>{children}</a>,
|
||||
}));
|
||||
|
||||
|
||||
102
src/components/BatteryStatus.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import React from "react";
|
||||
import {
|
||||
BatteryFullIcon,
|
||||
BatteryLowIcon,
|
||||
BatteryMediumIcon,
|
||||
PlugZapIcon,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DeviceMetrics } from "./types.ts";
|
||||
|
||||
type BatteryStatusKey = keyof typeof BATTERY_STATUS;
|
||||
|
||||
interface BatteryStatusProps {
|
||||
deviceMetrics?: DeviceMetrics | null;
|
||||
}
|
||||
|
||||
interface BatteryStatusProps {
|
||||
deviceMetrics?: DeviceMetrics | null;
|
||||
}
|
||||
|
||||
interface StatusConfig {
|
||||
Icon: React.ElementType;
|
||||
className: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
const BATTERY_STATUS = {
|
||||
PLUGGED_IN: "PLUGGED_IN",
|
||||
FULL: "FULL",
|
||||
MEDIUM: "MEDIUM",
|
||||
LOW: "LOW",
|
||||
} as const;
|
||||
|
||||
export const getBatteryStatus = (level: number): BatteryStatusKey => {
|
||||
if (level > 100) {
|
||||
return BATTERY_STATUS.PLUGGED_IN;
|
||||
}
|
||||
if (level > 80) {
|
||||
return BATTERY_STATUS.FULL;
|
||||
}
|
||||
if (level > 20) {
|
||||
return BATTERY_STATUS.MEDIUM;
|
||||
}
|
||||
return BATTERY_STATUS.LOW;
|
||||
};
|
||||
|
||||
const BatteryStatus: React.FC<BatteryStatusProps> = ({ deviceMetrics }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (
|
||||
deviceMetrics?.batteryLevel === undefined ||
|
||||
deviceMetrics?.batteryLevel === null
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { batteryLevel } = deviceMetrics;
|
||||
|
||||
const statusKey = getBatteryStatus(batteryLevel);
|
||||
|
||||
const statusConfigMap: Record<BatteryStatusKey, StatusConfig> = {
|
||||
[BATTERY_STATUS.PLUGGED_IN]: {
|
||||
Icon: PlugZapIcon,
|
||||
className: "text-gray-500",
|
||||
text: t("batteryStatus.pluggedIn"),
|
||||
},
|
||||
[BATTERY_STATUS.FULL]: {
|
||||
Icon: BatteryFullIcon,
|
||||
className: "text-green-500",
|
||||
text: t("batteryStatus.charging", { level: batteryLevel }),
|
||||
},
|
||||
[BATTERY_STATUS.MEDIUM]: {
|
||||
Icon: BatteryMediumIcon,
|
||||
className: "text-yellow-500",
|
||||
text: t("batteryStatus.charging", { level: batteryLevel }),
|
||||
},
|
||||
[BATTERY_STATUS.LOW]: {
|
||||
Icon: BatteryLowIcon,
|
||||
className: "text-red-500",
|
||||
text: t("batteryStatus.charging", { level: batteryLevel }),
|
||||
},
|
||||
};
|
||||
|
||||
// 3. Use the key to get the current state configuration
|
||||
const {
|
||||
Icon: BatteryIcon,
|
||||
className: iconClassName,
|
||||
text: statusText,
|
||||
} = statusConfigMap[statusKey];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-1 mt-0.5 "
|
||||
aria-label={t("batteryStatus.title")}
|
||||
>
|
||||
<BatteryIcon size={22} className={iconClassName} />
|
||||
{statusText}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BatteryStatus;
|
||||
@@ -17,8 +17,10 @@ import {
|
||||
FactoryIcon,
|
||||
LayersIcon,
|
||||
LinkIcon,
|
||||
type LucideIcon,
|
||||
MapIcon,
|
||||
MessageSquareIcon,
|
||||
Pin,
|
||||
PlusIcon,
|
||||
PowerIcon,
|
||||
QrCodeIcon,
|
||||
@@ -27,15 +29,16 @@ import {
|
||||
SmartphoneIcon,
|
||||
TrashIcon,
|
||||
UsersIcon,
|
||||
Pin,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { Avatar } from "@components/UI/Avatar.tsx";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usePinnedItems } from "@core/hooks/usePinnedItems.ts";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
|
||||
export interface Group {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
commands: Command[];
|
||||
@@ -57,72 +60,76 @@ export const CommandPalette = () => {
|
||||
const {
|
||||
commandPaletteOpen,
|
||||
setCommandPaletteOpen,
|
||||
setConnectDialogOpen,
|
||||
setSelectedDevice,
|
||||
} = useAppStore();
|
||||
const { getDevices } = useDeviceStore();
|
||||
const { setDialogOpen, setActivePage, connection } = useDevice();
|
||||
const { pinnedItems, togglePinnedItem } = usePinnedItems({ storageName: 'pinnedCommandMenuGroups' });
|
||||
const { setDialogOpen, getNode, connection } = useDevice();
|
||||
const { pinnedItems, togglePinnedItem } = usePinnedItems({
|
||||
storageName: "pinnedCommandMenuGroups",
|
||||
});
|
||||
const { t } = useTranslation("commandPalette");
|
||||
const navigate = useNavigate({ from: "/" });
|
||||
|
||||
const groups: Group[] = [
|
||||
{
|
||||
label: "Goto",
|
||||
id: "gotoGroup",
|
||||
label: t("goto.label"),
|
||||
icon: LinkIcon,
|
||||
commands: [
|
||||
{
|
||||
label: "Messages",
|
||||
label: t("goto.command.messages"),
|
||||
icon: MessageSquareIcon,
|
||||
action() {
|
||||
setActivePage("messages");
|
||||
navigate({ to: "/messages" });
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Map",
|
||||
label: t("goto.command.map"),
|
||||
icon: MapIcon,
|
||||
action() {
|
||||
setActivePage("map");
|
||||
navigate({ to: "/map" });
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Config",
|
||||
label: t("goto.command.config"),
|
||||
icon: SettingsIcon,
|
||||
action() {
|
||||
setActivePage("config");
|
||||
navigate({ to: "/config" });
|
||||
},
|
||||
tags: ["settings"],
|
||||
},
|
||||
{
|
||||
label: "Channels",
|
||||
label: t("goto.command.channels"),
|
||||
icon: LayersIcon,
|
||||
action() {
|
||||
setActivePage("channels");
|
||||
navigate({ to: "/channels" });
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Nodes",
|
||||
label: t("goto.command.nodes"),
|
||||
icon: UsersIcon,
|
||||
action() {
|
||||
setActivePage("nodes");
|
||||
navigate({ to: "/nodes" });
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Manage",
|
||||
id: "manageGroup",
|
||||
label: t("manage.label"),
|
||||
icon: SmartphoneIcon,
|
||||
commands: [
|
||||
{
|
||||
label: "Switch Node",
|
||||
label: t("manage.command.switchNode"),
|
||||
icon: ArrowLeftRightIcon,
|
||||
subItems: getDevices().map((device) => ({
|
||||
label:
|
||||
device.nodes.get(device.hardware.myNodeNum)?.user?.longName ??
|
||||
device.hardware.myNodeNum.toString(),
|
||||
label: getNode(device.hardware.myNodeNum)?.user?.longName ??
|
||||
t("unknown.shortName"),
|
||||
icon: (
|
||||
<Avatar
|
||||
text={
|
||||
device.nodes.get(device.hardware.myNodeNum)?.user?.shortName ??
|
||||
device.hardware.myNodeNum.toString()
|
||||
}
|
||||
text={getNode(device.hardware.myNodeNum)?.user?.shortName ??
|
||||
t("unknown.shortName")}
|
||||
/>
|
||||
),
|
||||
action() {
|
||||
@@ -131,31 +138,32 @@ export const CommandPalette = () => {
|
||||
})),
|
||||
},
|
||||
{
|
||||
label: "Connect New Node",
|
||||
label: t("manage.command.connectNewNode"),
|
||||
icon: PlusIcon,
|
||||
action() {
|
||||
setSelectedDevice(0);
|
||||
setConnectDialogOpen(true);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Contextual",
|
||||
id: "contextualGroup",
|
||||
label: t("contextual.label"),
|
||||
icon: BoxSelectIcon,
|
||||
commands: [
|
||||
{
|
||||
label: "QR Code",
|
||||
label: t("contextual.command.qrCode"),
|
||||
icon: QrCodeIcon,
|
||||
subItems: [
|
||||
{
|
||||
label: "Generator",
|
||||
label: t("contextual.command.qrGenerator"),
|
||||
icon: <QrCodeIcon size={16} />,
|
||||
action() {
|
||||
setDialogOpen("QR", true);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Import",
|
||||
label: t("contextual.command.qrImport"),
|
||||
icon: <QrCodeIcon size={16} />,
|
||||
action() {
|
||||
setDialogOpen("import", true);
|
||||
@@ -164,42 +172,42 @@ export const CommandPalette = () => {
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Schedule Shutdown",
|
||||
label: t("contextual.command.scheduleShutdown"),
|
||||
icon: PowerIcon,
|
||||
action() {
|
||||
setDialogOpen("shutdown", true);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Schedule Reboot",
|
||||
label: t("contextual.command.scheduleReboot"),
|
||||
icon: RefreshCwIcon,
|
||||
action() {
|
||||
setDialogOpen("reboot", true);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Reboot To OTA Mode",
|
||||
label: t("contextual.command.rebootToOtaMode"),
|
||||
icon: RefreshCwIcon,
|
||||
action() {
|
||||
setDialogOpen("rebootOTA", true);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Reset Nodes",
|
||||
label: t("contextual.command.resetNodeDb"),
|
||||
icon: TrashIcon,
|
||||
action() {
|
||||
connection?.resetNodes();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Factory Reset Device",
|
||||
label: t("contextual.command.factoryResetDevice"),
|
||||
icon: FactoryIcon,
|
||||
action() {
|
||||
connection?.factoryResetDevice();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Factory Reset Config",
|
||||
label: t("contextual.command.factoryResetConfig"),
|
||||
icon: FactoryIcon,
|
||||
action() {
|
||||
connection?.factoryResetConfig();
|
||||
@@ -208,21 +216,22 @@ export const CommandPalette = () => {
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Debug",
|
||||
id: "debugGroup",
|
||||
label: t("debug.label"),
|
||||
icon: BugIcon,
|
||||
commands: [
|
||||
{
|
||||
label: "Reconfigure",
|
||||
label: t("debug.command.reconfigure"),
|
||||
icon: RefreshCwIcon,
|
||||
action() {
|
||||
void connection?.configure();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "[WIP] Clear Messages",
|
||||
label: t("debug.command.clearAllStoredMessages"),
|
||||
icon: EraserIcon,
|
||||
action() {
|
||||
alert("This feature is not implemented");
|
||||
setDialogOpen("deleteMessages", true);
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -230,8 +239,8 @@ export const CommandPalette = () => {
|
||||
];
|
||||
|
||||
const sortedGroups = [...groups].sort((a, b) => {
|
||||
const aPinned = pinnedItems.includes(a.label) ? 1 : 0;
|
||||
const bPinned = pinnedItems.includes(b.label) ? 1 : 0;
|
||||
const aPinned = pinnedItems.includes(a.id) ? 1 : 0;
|
||||
const bPinned = pinnedItems.includes(b.id) ? 1 : 0;
|
||||
return bPinned - aPinned;
|
||||
});
|
||||
|
||||
@@ -248,10 +257,13 @@ export const CommandPalette = () => {
|
||||
}, [setCommandPaletteOpen]);
|
||||
|
||||
return (
|
||||
<CommandDialog open={commandPaletteOpen} onOpenChange={setCommandPaletteOpen}>
|
||||
<CommandInput placeholder="Type a command or search..." />
|
||||
<CommandDialog
|
||||
open={commandPaletteOpen}
|
||||
onOpenChange={setCommandPaletteOpen}
|
||||
>
|
||||
<CommandInput placeholder={t("search.commandPalette")} />
|
||||
<CommandList>
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
<CommandEmpty>{t("emptyState")}</CommandEmpty>
|
||||
{sortedGroups.map((group) => (
|
||||
<CommandGroup
|
||||
key={group.label}
|
||||
@@ -260,15 +272,13 @@ export const CommandPalette = () => {
|
||||
<span>{group.label}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => togglePinnedItem(group.label)}
|
||||
onClick={() => togglePinnedItem(group.id)}
|
||||
className={cn(
|
||||
"transition-all duration-300 scale-100 cursor-pointer m-0.5 p-2 focus:*:data-label:opacity-100"
|
||||
"transition-all duration-300 scale-100 cursor-pointer p-2 focus:*:data-label:opacity-100",
|
||||
)}
|
||||
aria-description={
|
||||
pinnedItems.includes(group.label)
|
||||
? "Unpin command group"
|
||||
: "Pin command group"
|
||||
}
|
||||
aria-description={pinnedItems.includes(group.label)
|
||||
? t("unpinGroup.label")
|
||||
: t("pinGroup.label")}
|
||||
>
|
||||
<span
|
||||
data-label
|
||||
@@ -278,9 +288,9 @@ export const CommandPalette = () => {
|
||||
size={16}
|
||||
className={cn(
|
||||
"transition-opacity",
|
||||
pinnedItems.includes(group.label)
|
||||
pinnedItems.includes(group.id)
|
||||
? "opacity-100 text-red-500"
|
||||
: "opacity-40 hover:opacity-70"
|
||||
: "opacity-40 hover:opacity-70",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
239
src/components/DeviceInfoPanel.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import {
|
||||
CpuIcon,
|
||||
Languages,
|
||||
type LucideIcon,
|
||||
Palette,
|
||||
PenLine,
|
||||
Search as SearchIcon,
|
||||
ZapIcon,
|
||||
} from "lucide-react";
|
||||
import BatteryStatus from "./BatteryStatus.tsx";
|
||||
import { Subtle } from "./UI/Typography/Subtle.tsx";
|
||||
import { Avatar } from "./UI/Avatar.tsx";
|
||||
import type { DeviceMetrics } from "./types.ts";
|
||||
import { Button } from "./UI/Button.tsx";
|
||||
import React, { Fragment } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ThemeSwitcher from "./ThemeSwitcher.tsx";
|
||||
import LanguageSwitcher from "./LanguageSwitcher.tsx";
|
||||
|
||||
interface DeviceInfoPanelProps {
|
||||
isCollapsed: boolean;
|
||||
deviceMetrics: DeviceMetrics;
|
||||
firmwareVersion: string;
|
||||
user: {
|
||||
shortName: string;
|
||||
longName: string;
|
||||
};
|
||||
setDialogOpen: () => void;
|
||||
setCommandPaletteOpen: () => void;
|
||||
disableHover?: boolean;
|
||||
}
|
||||
|
||||
interface InfoDisplayItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: LucideIcon;
|
||||
customComponent?: React.ReactNode;
|
||||
value?: string | number | null;
|
||||
}
|
||||
|
||||
interface ActionButtonConfig {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
onClick?: () => void;
|
||||
render?: () => React.ReactNode;
|
||||
}
|
||||
|
||||
export const DeviceInfoPanel = ({
|
||||
deviceMetrics,
|
||||
firmwareVersion,
|
||||
user,
|
||||
isCollapsed,
|
||||
setDialogOpen,
|
||||
setCommandPaletteOpen,
|
||||
disableHover = false,
|
||||
}: DeviceInfoPanelProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { batteryLevel, voltage } = deviceMetrics;
|
||||
|
||||
const deviceInfoItems: InfoDisplayItem[] = [
|
||||
{
|
||||
id: "battery",
|
||||
label: t("batteryStatus.title"),
|
||||
customComponent: <BatteryStatus deviceMetrics={deviceMetrics} />,
|
||||
value: batteryLevel !== undefined ? `${batteryLevel}%` : "N/A",
|
||||
},
|
||||
{
|
||||
id: "voltage",
|
||||
label: t("batteryVoltage.title"),
|
||||
icon: ZapIcon,
|
||||
value: voltage !== undefined
|
||||
? `${voltage?.toPrecision(3)} V`
|
||||
: t("unknown.notAvailable", "N/A"),
|
||||
},
|
||||
{
|
||||
id: "firmware",
|
||||
label: t("sidebar.deviceInfo.firmware.title"),
|
||||
icon: CpuIcon,
|
||||
value: firmwareVersion ?? t("unknown.notAvailable", "N/A"),
|
||||
},
|
||||
];
|
||||
|
||||
const actionButtons: ActionButtonConfig[] = [
|
||||
{
|
||||
id: "changeName",
|
||||
label: t("sidebar.deviceInfo.deviceName.changeName"),
|
||||
icon: PenLine,
|
||||
onClick: setDialogOpen,
|
||||
},
|
||||
{
|
||||
id: "commandMenu",
|
||||
label: t("page.title", { ns: "commandPalette" }),
|
||||
icon: SearchIcon,
|
||||
onClick: setCommandPaletteOpen,
|
||||
},
|
||||
{
|
||||
id: "theme",
|
||||
label: t("theme.changeTheme"),
|
||||
icon: Palette,
|
||||
render: () => <ThemeSwitcher />,
|
||||
},
|
||||
{
|
||||
id: "language",
|
||||
label: t("language.changeLanguage"),
|
||||
icon: Languages,
|
||||
render: () => <LanguageSwitcher />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3 p-1 flex-shrink-0",
|
||||
isCollapsed && "justify-center",
|
||||
)}
|
||||
>
|
||||
<Avatar
|
||||
text={user.shortName}
|
||||
className={cn("flex-shrink-0", isCollapsed && "")}
|
||||
size="sm"
|
||||
/>
|
||||
{!isCollapsed && (
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm font-medium text-gray-800 dark:text-gray-200",
|
||||
"transition-opacity duration-300 ease-in-out truncate",
|
||||
)}
|
||||
>
|
||||
{user.longName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isCollapsed && (
|
||||
<div className="my-2 h-px bg-gray-200 dark:bg-gray-700 flex-shrink-0">
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-2 mt-1",
|
||||
"transition-all duration-300 ease-in-out",
|
||||
isCollapsed
|
||||
? "opacity-0 max-w-0 h-0 invisible pointer-events-none"
|
||||
: "opacity-100 max-w-xs h-auto visible",
|
||||
)}
|
||||
>
|
||||
{deviceInfoItems.map((item) => {
|
||||
const IconComponent = item.icon;
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center gap-2.5 text-sm"
|
||||
>
|
||||
{IconComponent && (
|
||||
<IconComponent
|
||||
size={16}
|
||||
className="text-gray-500 dark:text-gray-400 w-4 flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
{item.customComponent}
|
||||
{item.id !== "battery" && (
|
||||
<Subtle className="text-gray-600 dark:text-gray-300">
|
||||
{item.label}: {item.value}
|
||||
</Subtle>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{!isCollapsed && (
|
||||
<div className="my-2 h-px bg-gray-200 dark:bg-gray-700 flex-shrink-0">
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-1 mt-1",
|
||||
"transition-all duration-300 ease-in-out",
|
||||
isCollapsed
|
||||
? "opacity-0 max-w-0 h-0 invisible pointer-events-none"
|
||||
: "opacity-100 max-w-xs visible",
|
||||
)}
|
||||
>
|
||||
{actionButtons.map((buttonItem) => {
|
||||
const Icon = buttonItem.icon;
|
||||
if (buttonItem.render) {
|
||||
return (
|
||||
<Fragment key={buttonItem.id}>
|
||||
{buttonItem.render()}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
key={buttonItem.id}
|
||||
variant="ghost"
|
||||
aria-label={buttonItem.label}
|
||||
onClick={buttonItem.onClick}
|
||||
className={cn(
|
||||
"group",
|
||||
"flex w-full items-center justify-start text-sm p-1.5 rounded-md",
|
||||
"gap-2.5",
|
||||
"transition-colors duration-150",
|
||||
!disableHover && "hover:bg-gray-100 dark:hover:bg-gray-700",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
size={16}
|
||||
className={cn(
|
||||
"flex-shrink-0 w-4",
|
||||
"text-gray-500 dark:text-gray-400",
|
||||
"transition-colors duration-150",
|
||||
!disableHover &&
|
||||
"group-hover:text-gray-700 dark:group-hover:text-gray-200",
|
||||
)}
|
||||
/>
|
||||
<Subtle
|
||||
className={cn(
|
||||
"text-sm",
|
||||
"text-gray-600 dark:text-gray-300",
|
||||
"transition-colors duration-150",
|
||||
!disableHover &&
|
||||
"group-hover:text-gray-800 dark:group-hover:text-gray-100",
|
||||
)}
|
||||
>
|
||||
{buttonItem.label}
|
||||
</Subtle>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,76 +0,0 @@
|
||||
import { DeviceSelectorButton } from "@components/DeviceSelectorButton.tsx";
|
||||
import ThemeSwitcher from "@components/ThemeSwitcher.tsx";
|
||||
import { Separator } from "@components/UI/Seperator.tsx";
|
||||
import { Code } from "@components/UI/Typography/Code.tsx";
|
||||
import { useAppStore } from "@core/stores/appStore.ts";
|
||||
import { useDeviceStore } from "@core/stores/deviceStore.ts";
|
||||
import { HomeIcon, PlusIcon, SearchIcon } from "lucide-react";
|
||||
import { Avatar } from "@components/UI/Avatar.tsx";
|
||||
|
||||
export const DeviceSelector = () => {
|
||||
const { getDevices } = useDeviceStore();
|
||||
const {
|
||||
selectedDevice,
|
||||
setSelectedDevice,
|
||||
setCommandPaletteOpen,
|
||||
setConnectDialogOpen,
|
||||
} = useAppStore();
|
||||
|
||||
return (
|
||||
<nav className="flex flex-col justify-between border-r-[0.5px] border-slate-300 pt-2 dark:border-slate-700">
|
||||
<div className="flex flex-col overflow-y-hidden">
|
||||
<ul className="flex w-20 grow flex-col items-center space-y-4 bg-transparent py-4 px-5">
|
||||
<DeviceSelectorButton
|
||||
active={selectedDevice === 0}
|
||||
onClick={() => {
|
||||
setSelectedDevice(0);
|
||||
}}
|
||||
>
|
||||
<HomeIcon />
|
||||
</DeviceSelectorButton>
|
||||
{getDevices().map((device) => (
|
||||
<DeviceSelectorButton
|
||||
key={device.id}
|
||||
onClick={() => {
|
||||
setSelectedDevice(device.id);
|
||||
}}
|
||||
active={selectedDevice === device.id}
|
||||
>
|
||||
<Avatar
|
||||
text={device.nodes
|
||||
.get(device.hardware.myNodeNum)
|
||||
?.user?.shortName.toString() ?? "UNK"}
|
||||
/>
|
||||
</DeviceSelectorButton>
|
||||
))}
|
||||
<Separator />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConnectDialogOpen(true)}
|
||||
className="transition-all duration-300"
|
||||
>
|
||||
<PlusIcon />
|
||||
</button>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="flex w-20 flex-col items-center space-y-5 px-5 pb-5">
|
||||
<ThemeSwitcher />
|
||||
<button
|
||||
type="button"
|
||||
className="transition-all hover:text-accent"
|
||||
onClick={() => setCommandPaletteOpen(true)}
|
||||
>
|
||||
<SearchIcon />
|
||||
</button>
|
||||
{/* TODO: This is being commented out until its fixed */}
|
||||
{
|
||||
/* <button type="button" className="transition-all hover:text-accent">
|
||||
<LanguagesIcon />
|
||||
</button> */
|
||||
}
|
||||
<Separator />
|
||||
<Code>{import.meta.env.VITE_COMMIT_HASH}</Code>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
export interface DeviceSelectorButtonProps {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const DeviceSelectorButton = ({
|
||||
onClick,
|
||||
children,
|
||||
}: DeviceSelectorButtonProps) => (
|
||||
<li
|
||||
className="aspect-w-1 aspect-h-1 relative w-full"
|
||||
onClick={onClick}
|
||||
onKeyDown={onClick}
|
||||
>
|
||||
{
|
||||
/* {active && (
|
||||
<div className="absolute -left-2 h-10 w-1.5 rounded-full bg-accent" />
|
||||
)} */
|
||||
}
|
||||
<div className="flex aspect-square cursor-pointer flex-col items-center justify-center">
|
||||
{children}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
@@ -0,0 +1,80 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
// Ensure the path is correct for import
|
||||
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
|
||||
import { DeleteMessagesDialog } from "@components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx";
|
||||
|
||||
vi.mock("@core/stores/messageStore", () => ({
|
||||
useMessageStore: vi.fn(() => ({
|
||||
deleteAllMessages: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("DeleteMessagesDialog", () => {
|
||||
const mockOnOpenChange = vi.fn();
|
||||
const mockClearAllMessages = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
mockOnOpenChange.mockClear();
|
||||
mockClearAllMessages.mockClear();
|
||||
|
||||
const mockedUseMessageStore = vi.mocked(useMessageStore);
|
||||
mockedUseMessageStore.mockImplementation(() => ({
|
||||
deleteAllMessages: mockClearAllMessages,
|
||||
}));
|
||||
mockedUseMessageStore.mockClear();
|
||||
});
|
||||
|
||||
it("calls onOpenChange with false when the close button (X) is clicked", () => {
|
||||
render(
|
||||
<DeleteMessagesDialog open onOpenChange={mockOnOpenChange} />,
|
||||
);
|
||||
const closeButton = screen.queryByTestId("dialog-close-button");
|
||||
if (!closeButton) {
|
||||
throw new Error(
|
||||
"Dialog close button with data-testid='dialog-close-button' not found. Did you add it to the component?",
|
||||
);
|
||||
}
|
||||
fireEvent.click(closeButton);
|
||||
expect(mockOnOpenChange).toHaveBeenCalledTimes(1);
|
||||
expect(mockOnOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("renders the dialog when open is true", () => {
|
||||
render(
|
||||
<DeleteMessagesDialog open onOpenChange={mockOnOpenChange} />,
|
||||
);
|
||||
expect(screen.getByText("Clear All Messages")).toBeInTheDocument();
|
||||
expect(screen.getByText(/This action will clear all message history./))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Dismiss" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Clear Messages" }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render the dialog when open is false", () => {
|
||||
render(
|
||||
<DeleteMessagesDialog open={false} onOpenChange={mockOnOpenChange} />,
|
||||
);
|
||||
expect(screen.queryByText("Clear All Messages")).toBeNull();
|
||||
});
|
||||
|
||||
it("calls onOpenChange with false when the dismiss button is clicked", () => {
|
||||
render(
|
||||
<DeleteMessagesDialog open onOpenChange={mockOnOpenChange} />,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
|
||||
expect(mockOnOpenChange).toHaveBeenCalledTimes(1); // Add count check
|
||||
expect(mockOnOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("calls deleteAllMessages and onOpenChange with false when the clear messages button is clicked", () => {
|
||||
render(
|
||||
<DeleteMessagesDialog open onOpenChange={mockOnOpenChange} />,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clear Messages" }));
|
||||
expect(mockClearAllMessages).toHaveBeenCalledTimes(1);
|
||||
expect(mockOnOpenChange).toHaveBeenCalledTimes(1); // Add count check
|
||||
expect(mockOnOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@components/UI/Dialog.tsx";
|
||||
import { AlertTriangleIcon } from "lucide-react";
|
||||
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface DeleteMessagesDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const DeleteMessagesDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: DeleteMessagesDialogProps) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
const { deleteAllMessages } = useMessageStore();
|
||||
const handleCloseDialog = () => {
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogClose data-testid="dialog-close-button" />
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangleIcon className="h-5 w-5 text-warning" />
|
||||
{t("deleteMessages.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("deleteMessages.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCloseDialog}
|
||||
name="dismiss"
|
||||
>
|
||||
{t("button.dismiss")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
deleteAllMessages();
|
||||
handleCloseDialog();
|
||||
}}
|
||||
name="clearMessages"
|
||||
>
|
||||
{t("button.clearMessages")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -10,10 +10,13 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@components/UI/Dialog.tsx";
|
||||
import { Input } from "@components/UI/Input.tsx";
|
||||
import { Label } from "@components/UI/Label.tsx";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { GenericInput } from "@components/Form/FormInput.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Label } from "../UI/Label.tsx";
|
||||
import z from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
|
||||
export interface User {
|
||||
longName: string;
|
||||
@@ -29,52 +32,116 @@ export const DeviceNameDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: DeviceNameDialogProps) => {
|
||||
const { hardware, nodes, connection } = useDevice();
|
||||
const { t } = useTranslation("dialog");
|
||||
const { hardware, getNode, connection } = useDevice();
|
||||
const myNode = getNode(hardware.myNodeNum);
|
||||
|
||||
const myNode = nodes.get(hardware.myNodeNum);
|
||||
const defaultValues = {
|
||||
shortName: myNode?.user?.shortName ?? "",
|
||||
longName: myNode?.user?.longName ?? "",
|
||||
};
|
||||
|
||||
const { register, handleSubmit } = useForm<User>({
|
||||
values: {
|
||||
longName: myNode?.user?.longName ?? "Unknown",
|
||||
shortName: myNode?.user?.shortName ?? "Unknown",
|
||||
},
|
||||
const deviceNameSchema = z.object({
|
||||
longName: z
|
||||
.string()
|
||||
.min(1, t("deviceName.validation.longNameMin"))
|
||||
.max(40, t("deviceName.validation.longNameMax")),
|
||||
shortName: z
|
||||
.string()
|
||||
.min(2, t("deviceName.validation.shortNameMin"))
|
||||
.max(4, t("deviceName.validation.shortNameMax")),
|
||||
});
|
||||
|
||||
const { getValues, reset, control, handleSubmit } = useForm<User>({
|
||||
values: defaultValues,
|
||||
resolver: zodResolver(deviceNameSchema),
|
||||
});
|
||||
|
||||
const onSubmit = handleSubmit((data) => {
|
||||
connection?.setOwner(
|
||||
create(Protobuf.Mesh.UserSchema, {
|
||||
...myNode?.user,
|
||||
...data,
|
||||
}),
|
||||
);
|
||||
onOpenChange(false);
|
||||
});
|
||||
|
||||
const handleReset = () => {
|
||||
reset(defaultValues);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>Change Device Name</DialogTitle>
|
||||
<DialogTitle>{t("deviceName.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
The Device will restart once the config is saved.
|
||||
{t("deviceName.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="gap-4">
|
||||
<form onSubmit={onSubmit}>
|
||||
<Label>Long Name</Label>
|
||||
<Input className="dark:text-slte-900" {...register("longName")} />
|
||||
<Label>Short Name</Label>
|
||||
<Input
|
||||
className="dark:text-slte-900"
|
||||
maxLength={4}
|
||||
{...register("shortName")}
|
||||
<form onSubmit={onSubmit} className="flex flex-col gap-4">
|
||||
<div>
|
||||
<Label htmlFor="longName">
|
||||
{t("deviceName.longName")}
|
||||
</Label>
|
||||
<GenericInput
|
||||
control={control}
|
||||
field={{
|
||||
name: "longName",
|
||||
label: t("deviceName.longName"),
|
||||
type: "text",
|
||||
properties: {
|
||||
className: "text-slate-900 dark:text-slate-200",
|
||||
fieldLength: {
|
||||
currentValueLength: getValues("longName").length,
|
||||
max: 40,
|
||||
min: 1,
|
||||
showCharacterCount: true,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => onSubmit()}>Save</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="shortName">
|
||||
{t("deviceName.shortName")}
|
||||
</Label>
|
||||
<GenericInput
|
||||
control={control}
|
||||
field={{
|
||||
name: "shortName",
|
||||
label: t("deviceName.shortName"),
|
||||
type: "text",
|
||||
properties: {
|
||||
fieldLength: {
|
||||
currentValueLength: getValues("shortName").length,
|
||||
max: 4,
|
||||
min: 1,
|
||||
showCharacterCount: true,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
name="reset"
|
||||
onClick={handleReset}
|
||||
>
|
||||
{t("button.reset")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
name="save"
|
||||
>
|
||||
{t("button.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDeta
|
||||
import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx";
|
||||
import { RefreshKeysDialog } from "@components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx";
|
||||
import { RebootOTADialog } from "@components/Dialog/RebootOTADialog.tsx";
|
||||
import { DeleteMessagesDialog } from "@components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx";
|
||||
|
||||
export const DialogManager = () => {
|
||||
const { channels, config, dialog, setDialogOpen } = useDevice();
|
||||
@@ -84,6 +85,12 @@ export const DialogManager = () => {
|
||||
setDialogOpen("rebootOTA", open);
|
||||
}}
|
||||
/>
|
||||
<DeleteMessagesDialog
|
||||
open={dialog.deleteMessages}
|
||||
onOpenChange={(open) => {
|
||||
setDialogOpen("deleteMessages", open);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { toByteArray } from "base64-js";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface ImportDialogProps {
|
||||
open: boolean;
|
||||
@@ -28,6 +29,7 @@ export const ImportDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: ImportDialogProps) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
const [importDialogInput, setImportDialogInput] = useState<string>("");
|
||||
const [channelSet, setChannelSet] = useState<Protobuf.AppOnly.ChannelSet>();
|
||||
const [validUrl, setValidUrl] = useState<boolean>(false);
|
||||
@@ -44,14 +46,14 @@ export const ImportDialog = ({
|
||||
channelsUrl.pathname !== "/e/") ||
|
||||
!channelsUrl.hash
|
||||
) {
|
||||
throw "Invalid Meshtastic URL";
|
||||
throw t("import.error.invalidUrl");
|
||||
}
|
||||
|
||||
const encodedChannelConfig = channelsUrl.hash.substring(1);
|
||||
const paddedString = encodedChannelConfig
|
||||
.padEnd(
|
||||
encodedChannelConfig.length +
|
||||
((4 - (encodedChannelConfig.length % 4)) % 4),
|
||||
((4 - (encodedChannelConfig.length % 4)) % 4),
|
||||
"=",
|
||||
)
|
||||
.replace(/-/g, "+")
|
||||
@@ -70,17 +72,19 @@ export const ImportDialog = ({
|
||||
}, [importDialogInput]);
|
||||
|
||||
const apply = () => {
|
||||
channelSet?.settings.map((ch: unknown, index: number) => {
|
||||
connection?.setChannel(
|
||||
create(Protobuf.Channel.ChannelSchema, {
|
||||
index,
|
||||
role: index === 0
|
||||
? Protobuf.Channel.Channel_Role.PRIMARY
|
||||
: Protobuf.Channel.Channel_Role.SECONDARY,
|
||||
settings: ch,
|
||||
}),
|
||||
);
|
||||
});
|
||||
channelSet?.settings.map(
|
||||
(ch: Protobuf.Channel.ChannelSettings, index: number) => {
|
||||
connection?.setChannel(
|
||||
create(Protobuf.Channel.ChannelSchema, {
|
||||
index,
|
||||
role: index === 0
|
||||
? Protobuf.Channel.Channel_Role.PRIMARY
|
||||
: Protobuf.Channel.Channel_Role.SECONDARY,
|
||||
settings: ch,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (channelSet?.loraConfig) {
|
||||
connection?.setConfig(
|
||||
@@ -99,17 +103,16 @@ export const ImportDialog = ({
|
||||
<DialogContent>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>Import Channel Set</DialogTitle>
|
||||
<DialogTitle>{t("import.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
The current LoRa configuration will be overridden.
|
||||
{t("import.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label>Channel Set/QR Code URL</Label>
|
||||
<Label>{t("import.channelSetUrl")}</Label>
|
||||
<Input
|
||||
value={importDialogInput}
|
||||
suffix={validUrl ? "✅" : "❌"}
|
||||
className="dark:text-slate-900"
|
||||
onChange={(e) => {
|
||||
setImportDialogInput(e.target.value);
|
||||
}}
|
||||
@@ -118,7 +121,7 @@ export const ImportDialog = ({
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex w-full gap-2">
|
||||
<div className="w-36">
|
||||
<Label>Use Preset?</Label>
|
||||
<Label>{t("import.usePreset")}</Label>
|
||||
<Switch
|
||||
disabled
|
||||
checked={channelSet?.loraConfig?.usePreset ?? true}
|
||||
@@ -145,7 +148,7 @@ export const ImportDialog = ({
|
||||
}
|
||||
|
||||
<span className="text-md block font-medium text-text-primary">
|
||||
Channels:
|
||||
{t("import.channels")}
|
||||
</span>
|
||||
<div className="flex w-40 flex-col gap-1">
|
||||
{channelSet?.settings.map((channel) => (
|
||||
@@ -153,7 +156,7 @@ export const ImportDialog = ({
|
||||
<Label>
|
||||
{channel.name.length
|
||||
? channel.name
|
||||
: `Channel: ${channel.id}`}
|
||||
: `${t("import.channelPrefix")}${channel.id}`}
|
||||
</Label>
|
||||
<Checkbox key={channel.id} />
|
||||
</div>
|
||||
@@ -163,8 +166,8 @@ export const ImportDialog = ({
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={apply} disabled={!validUrl}>
|
||||
Apply
|
||||
<Button onClick={apply} disabled={!validUrl} name="apply">
|
||||
{t("button.apply")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useDevice } from "../../core/stores/deviceStore.ts";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
@@ -9,9 +9,10 @@ import {
|
||||
} from "../UI/Dialog.tsx";
|
||||
import type { Protobuf, Types } from "@meshtastic/core";
|
||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface LocationResponseDialogProps {
|
||||
location: Types.PacketMetadata<Protobuf.Mesh.location> | undefined;
|
||||
location: Types.PacketMetadata<Protobuf.Mesh.Position> | undefined;
|
||||
open: boolean;
|
||||
onOpenChange: () => void;
|
||||
}
|
||||
@@ -21,40 +22,70 @@ export const LocationResponseDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: LocationResponseDialogProps) => {
|
||||
const { nodes } = useDevice();
|
||||
const { t } = useTranslation("dialog");
|
||||
const { getNode } = useDevice();
|
||||
|
||||
const from = nodes.get(location?.from ?? 0);
|
||||
const from = getNode(location?.from ?? 0);
|
||||
const longName = from?.user?.longName ??
|
||||
(from ? `!${numberToHexUnpadded(from?.num)}` : "Unknown");
|
||||
(from ? `!${numberToHexUnpadded(from?.num)}` : t("unknown.shortName"));
|
||||
const shortName = from?.user?.shortName ??
|
||||
(from ? `${numberToHexUnpadded(from?.num).substring(0, 4)}` : "UNK");
|
||||
(from
|
||||
? `${numberToHexUnpadded(from?.num).substring(0, 4)}`
|
||||
: t("unknown.shortName"));
|
||||
|
||||
const position = location?.data;
|
||||
|
||||
const hasCoordinates = position &&
|
||||
typeof position.latitudeI === "number" &&
|
||||
typeof position.longitudeI === "number" &&
|
||||
typeof position.altitude === "number";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>{`Location: ${longName} (${shortName})`}</DialogTitle>
|
||||
<DialogTitle>
|
||||
{t("locationResponse.title", {
|
||||
identifier: `${longName} (${shortName})`,
|
||||
})}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogDescription>
|
||||
<div className="ml-5 flex">
|
||||
<span className="ml-4 border-l-2 border-l-backgroundPrimary pl-2 text-textPrimary">
|
||||
<p>
|
||||
Coordinates:{" "}
|
||||
<a
|
||||
className="text-blue-500 dark:text-blue-400"
|
||||
href={`https://www.openstreetmap.org/?mlat=${location?.data.latitudeI / 1e7
|
||||
}&mlon=${location?.data.longitudeI / 1e7}&layers=N`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{location?.data.latitudeI / 1e7},{" "}
|
||||
{location?.data.longitudeI / 1e7}
|
||||
</a>
|
||||
{hasCoordinates
|
||||
? (
|
||||
<div className="ml-5 flex">
|
||||
<span className="ml-4 border-l-2 border-l-backgroundPrimary pl-2 text-textPrimary">
|
||||
<p>
|
||||
{t("locationResponse.coordinates")}
|
||||
<a
|
||||
className="text-blue-500 dark:text-blue-400"
|
||||
href={`https://www.openstreetmap.org/?mlat=${
|
||||
position.latitudeI ?? 0 / 1e7
|
||||
}&mlon=${position.longitudeI ?? 0 / 1e7}&layers=N`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{" "}
|
||||
{position.latitudeI ?? 0 / 1e7},{" "}
|
||||
{position.longitudeI ?? 0 / 1e7}
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
{t("locationResponse.altitude")} {position.altitude}
|
||||
{(position.altitude ?? 0) < 1
|
||||
? t("unit.meter.one")
|
||||
: t("unit.meter.plural")}
|
||||
</p>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
// Optional: Show a message if coordinates are not available
|
||||
<p className="text-textPrimary">
|
||||
{t("locationResponse.noCoordinates")}
|
||||
</p>
|
||||
<p>Altitude: {location?.data.altitude}m</p>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
72
src/components/Dialog/ManagedModeDialog.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@components/UI/Dialog.tsx";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Checkbox } from "@components/UI/Checkbox/index.tsx";
|
||||
import { useState } from "react";
|
||||
|
||||
export interface ManagedModeDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: () => void;
|
||||
onSubmit: () => void;
|
||||
}
|
||||
|
||||
export const ManagedModeDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: ManagedModeDialogProps) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
const [confirmState, setConfirmState] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("managedMode.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans
|
||||
i18nKey="managedMode.description"
|
||||
components={{
|
||||
"bold": <p className="font-bold inline" />,
|
||||
}}
|
||||
/>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="managedMode"
|
||||
checked={confirmState}
|
||||
onChange={() => setConfirmState(!confirmState)}
|
||||
name="confirmUnderstanding"
|
||||
>
|
||||
<p className="dark:text-white pt-1">
|
||||
{t("managedMode.confirmUnderstanding")}
|
||||
</p>
|
||||
</Checkbox>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="destructive"
|
||||
name="regenerate"
|
||||
disabled={!confirmState}
|
||||
onClick={() => {
|
||||
setConfirmState(false);
|
||||
onSubmit();
|
||||
}}
|
||||
>
|
||||
{t("button.confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
type BrowserFeature,
|
||||
useBrowserFeatureDetection,
|
||||
} from "../../core/hooks/useBrowserFeatureDetection.ts";
|
||||
} from "@core/hooks/useBrowserFeatureDetection.ts";
|
||||
import { BLE } from "@components/PageComponents/Connect/BLE.tsx";
|
||||
import { HTTP } from "@components/PageComponents/Connect/HTTP.tsx";
|
||||
import { Serial } from "@components/PageComponents/Connect/Serial.tsx";
|
||||
@@ -18,16 +18,16 @@ import {
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@components/UI/Tabs.tsx";
|
||||
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Link } from "../UI/Typography/Link.tsx";
|
||||
import { Fragment } from "react/jsx-runtime";
|
||||
|
||||
export interface TabElementProps {
|
||||
closeDialog: () => void;
|
||||
}
|
||||
|
||||
export interface TabManifest {
|
||||
id: "HTTP" | "BLE" | "Serial";
|
||||
label: string;
|
||||
element: React.FC<TabElementProps>;
|
||||
isDisabled: boolean;
|
||||
@@ -40,23 +40,28 @@ export interface NewDeviceProps {
|
||||
|
||||
interface FeatureErrorProps {
|
||||
missingFeatures: BrowserFeature[];
|
||||
tabId: "HTTP" | "BLE" | "Serial";
|
||||
}
|
||||
|
||||
const links: { [key: string]: string } = {
|
||||
"Web Bluetooth":
|
||||
"https://developer.mozilla.org/en-US/docs/Web/API/Web_Bluetooth_API#browser_compatibility",
|
||||
"Web Serial":
|
||||
"https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API#browser_compatibility",
|
||||
"Secure Context":
|
||||
"https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts",
|
||||
const errors: Record<BrowserFeature, { href: string; i18nKey: string }> = {
|
||||
"Web Bluetooth": {
|
||||
href:
|
||||
"https://developer.mozilla.org/en-US/docs/Web/API/Web_Bluetooth_API#browser_compatibility",
|
||||
i18nKey: "newDeviceDialog.validation.requiresWebBluetooth",
|
||||
},
|
||||
"Web Serial": {
|
||||
href:
|
||||
"https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API#browser_compatibility",
|
||||
i18nKey: "newDeviceDialog.validation.requiresWebSerial",
|
||||
},
|
||||
"Secure Context": {
|
||||
href:
|
||||
"https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts",
|
||||
i18nKey: "newDeviceDialog.validation.requiresSecureContext",
|
||||
},
|
||||
};
|
||||
|
||||
const listFormatter = new Intl.ListFormat("en", {
|
||||
style: "long",
|
||||
type: "disjunction",
|
||||
});
|
||||
|
||||
const ErrorMessage = ({ missingFeatures }: FeatureErrorProps) => {
|
||||
const ErrorMessage = ({ missingFeatures, tabId }: FeatureErrorProps) => {
|
||||
if (missingFeatures.length === 0) return null;
|
||||
|
||||
const browserFeatures = missingFeatures.filter(
|
||||
@@ -64,46 +69,51 @@ const ErrorMessage = ({ missingFeatures }: FeatureErrorProps) => {
|
||||
);
|
||||
const needsSecureContext = missingFeatures.includes("Secure Context");
|
||||
|
||||
const formatFeatureList = (features: string[]) => {
|
||||
const parts = listFormatter.formatToParts(features);
|
||||
return parts.map((part) => {
|
||||
if (part.type === "element") {
|
||||
return (
|
||||
<Link key={part.value} href={links[part.value]}>
|
||||
{part.value}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return <Fragment key={part.value}>{part.value}</Fragment>;
|
||||
});
|
||||
};
|
||||
const needsFeature =
|
||||
(tabId === "BLE" && browserFeatures.includes("Web Bluetooth"))
|
||||
? "Web Bluetooth"
|
||||
: (tabId === "Serial" && browserFeatures.includes("Web Serial"))
|
||||
? "Web Serial"
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Subtle className="flex flex-col items-start gap-2 bg-red-500 p-4 rounded-md">
|
||||
<div className="flex flex-col items-start gap-2 bg-red-500 p-4 rounded-md text-sm text-slate-500 dark:text-slate-400">
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<AlertCircle size={40} className="mr-2 shrink-0 text-white" />
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-sm text-white">
|
||||
{browserFeatures.length > 0 && (
|
||||
<>
|
||||
This connection type requires{" "}
|
||||
{formatFeatureList(browserFeatures)}. Please use a
|
||||
supported browser, like Chrome or Edge.
|
||||
</>
|
||||
<div className="text-sm text-white">
|
||||
{needsFeature && (
|
||||
<Trans
|
||||
i18nKey={errors[needsFeature].i18nKey}
|
||||
components={[
|
||||
<Link
|
||||
key="0"
|
||||
href={errors[needsFeature].href}
|
||||
className="underline hover:text-slate-200 text-white dark:text-white dark:hover:text-slate-300"
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
{needsFeature && needsSecureContext && " "}
|
||||
{needsSecureContext && (
|
||||
<>
|
||||
{browserFeatures.length > 0 && " Additionally, it"}
|
||||
{browserFeatures.length === 0 && "This application"} requires a
|
||||
{" "}
|
||||
<Link href={links["Secure Context"]}>secure context</Link>.
|
||||
Please connect using HTTPS or localhost.
|
||||
</>
|
||||
<Trans
|
||||
i18nKey={browserFeatures.length > 0
|
||||
? "newDeviceDialog.validation.additionallyRequiresSecureContext"
|
||||
: "newDeviceDialog.validation.requiresSecureContext"}
|
||||
components={{
|
||||
"0": (
|
||||
<Link
|
||||
href={errors["Secure Context"].href}
|
||||
className="underline hover:text-slate-200"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Subtle>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -111,22 +121,26 @@ export const NewDeviceDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: NewDeviceProps) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
const { unsupported } = useBrowserFeatureDetection();
|
||||
|
||||
const tabs: TabManifest[] = [
|
||||
{
|
||||
label: "HTTP",
|
||||
id: "HTTP",
|
||||
label: t("newDeviceDialog.tabHttp"),
|
||||
element: HTTP,
|
||||
isDisabled: false,
|
||||
},
|
||||
{
|
||||
label: "Bluetooth",
|
||||
id: "BLE",
|
||||
label: t("newDeviceDialog.tabBluetooth"),
|
||||
element: BLE,
|
||||
isDisabled: unsupported.includes("Web Bluetooth") ||
|
||||
unsupported.includes("Secure Context"),
|
||||
},
|
||||
{
|
||||
label: "Serial",
|
||||
id: "Serial",
|
||||
label: t("newDeviceDialog.tabSerial"),
|
||||
element: Serial,
|
||||
isDisabled: unsupported.includes("Web Serial") ||
|
||||
unsupported.includes("Secure Context"),
|
||||
@@ -135,26 +149,35 @@ export const NewDeviceDialog = ({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogContent aria-describedby={undefined}>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>Connect New Device</DialogTitle>
|
||||
<DialogTitle>{t("newDeviceDialog.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Tabs defaultValue="HTTP">
|
||||
<TabsList>
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger key={tab.label} value={tab.label}>
|
||||
<TabsTrigger key={tab.id} value={tab.id}>
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{tabs.map((tab) => (
|
||||
<TabsContent key={tab.label} value={tab.label}>
|
||||
<TabsContent key={tab.id} value={tab.id}>
|
||||
<fieldset disabled={tab.isDisabled}>
|
||||
{tab.isDisabled
|
||||
? <ErrorMessage missingFeatures={unsupported} />
|
||||
: null}
|
||||
<tab.element closeDialog={() => onOpenChange(false)} />
|
||||
{(tab.id !== "HTTP" &&
|
||||
tab.isDisabled)
|
||||
? (
|
||||
<ErrorMessage
|
||||
missingFeatures={unsupported}
|
||||
tabId={tab.id}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<tab.element
|
||||
closeDialog={() => onOpenChange(false)}
|
||||
/>
|
||||
)}
|
||||
</fieldset>
|
||||
</TabsContent>
|
||||
))}
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import { describe, it, vi, expect, beforeEach, Mock } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { useAppStore } from "@core/stores/appStore.ts";
|
||||
|
||||
vi.mock("@core/stores/deviceStore");
|
||||
vi.mock("@core/stores/appStore");
|
||||
|
||||
describe("NodeDetailsDialog", () => {
|
||||
const mockDevice = {
|
||||
num: 1234,
|
||||
user: {
|
||||
longName: "Test Node",
|
||||
shortName: "TN",
|
||||
hwModel: 1,
|
||||
role: 1,
|
||||
},
|
||||
lastHeard: 1697500000,
|
||||
position: {
|
||||
latitudeI: 450000000,
|
||||
longitudeI: -750000000,
|
||||
altitude: 200,
|
||||
},
|
||||
deviceMetrics: {
|
||||
airUtilTx: 50.123,
|
||||
channelUtilization: 75.456,
|
||||
batteryLevel: 88.789,
|
||||
voltage: 4.2,
|
||||
uptimeSeconds: 3600,
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset mocks before each test
|
||||
vi.resetAllMocks();
|
||||
|
||||
(useDevice as Mock).mockReturnValue({
|
||||
nodes: new Map([[1234, mockDevice]]),
|
||||
});
|
||||
|
||||
(useAppStore as unknown as Mock).mockReturnValue({
|
||||
nodeNumDetails: 1234,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders node details correctly", () => {
|
||||
render(<NodeDetailsDialog open={true} onOpenChange={() => { }} />);
|
||||
|
||||
expect(screen.getByText(/Node Details for Test Node/i)).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText("Node Number: 1234")).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(/Air TX utilization: 50.12%/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Channel utilization: 75.46%/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Battery level: 88.79%/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Voltage: 4.20V/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Uptime:/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Coordinates:/i)).toBeInTheDocument();
|
||||
expect(screen.getByText("45, -75")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Altitude: 200m/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Role:/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders null if device is not found", () => {
|
||||
(useDevice as Mock).mockReturnValue({
|
||||
nodes: new Map(),
|
||||
});
|
||||
|
||||
render(<NodeDetailsDialog open={true} onOpenChange={() => { }} />);
|
||||
expect(screen.queryByText(/Node Details for/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,16 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAppStore } from "@core/stores/appStore.ts";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||
import { DeviceImage } from "@components/generic/DeviceImage.tsx";
|
||||
import { TimeAgo } from "@components/generic/TimeAgo.tsx";
|
||||
import { Uptime } from "@components/generic/Uptime.tsx";
|
||||
import { toast } from "@core/hooks/useToast.ts";
|
||||
import { useFavoriteNode } from "@core/hooks/useFavoriteNode.ts";
|
||||
import { useIgnoreNode } from "@core/hooks/useIgnoreNode.ts";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
@@ -14,11 +25,26 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@components/UI/Dialog.tsx";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||
import { DeviceImage } from "@components/generic/DeviceImage.tsx";
|
||||
import { TimeAgo } from "@components/generic/TimeAgo.tsx";
|
||||
import { Uptime } from "@components/generic/Uptime.tsx";
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import {
|
||||
BellIcon,
|
||||
BellOffIcon,
|
||||
MapPinnedIcon,
|
||||
MessageSquareIcon,
|
||||
StarIcon,
|
||||
TrashIcon,
|
||||
WaypointsIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipArrow,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@components/UI/Tooltip.tsx";
|
||||
import { Separator } from "@components/UI/Seperator.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
|
||||
export interface NodeDetailsDialogProps {
|
||||
open: boolean;
|
||||
@@ -29,110 +55,287 @@ export const NodeDetailsDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: NodeDetailsDialogProps) => {
|
||||
const { nodes } = useDevice();
|
||||
const { nodeNumDetails } = useAppStore();
|
||||
const { t } = useTranslation("dialog");
|
||||
const { setDialogOpen, connection, getNode } = useDevice();
|
||||
const navigate = useNavigate();
|
||||
const { setNodeNumToBeRemoved, nodeNumDetails } = useAppStore();
|
||||
const { updateFavorite } = useFavoriteNode();
|
||||
const { updateIgnored } = useIgnoreNode();
|
||||
|
||||
const device = nodes.get(nodeNumDetails);
|
||||
const node = getNode(nodeNumDetails);
|
||||
|
||||
if (!device) return null;
|
||||
const [isFavoriteState, setIsFavoriteState] = useState<boolean>(
|
||||
node?.isFavorite ?? false,
|
||||
);
|
||||
const [isIgnoredState, setIsIgnoredState] = useState<boolean>(
|
||||
node?.isIgnored ?? false,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!node) return;
|
||||
setIsFavoriteState(node?.isFavorite);
|
||||
setIsIgnoredState(node?.isIgnored);
|
||||
}, [node]);
|
||||
|
||||
if (!node) return;
|
||||
|
||||
function handleDirectMessage() {
|
||||
if (!node) return;
|
||||
navigate({ to: `/messages/direct/${node.num}` });
|
||||
setDialogOpen("nodeDetails", false);
|
||||
}
|
||||
|
||||
function handleRequestPosition() {
|
||||
if (!node) return;
|
||||
|
||||
toast({
|
||||
title: t("toast.requestingPosition.title", { ns: "ui" }),
|
||||
});
|
||||
connection?.requestPosition(node.num).then(() =>
|
||||
toast({
|
||||
title: t("toast.positionRequestSent.title", { ns: "ui" }),
|
||||
})
|
||||
);
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
function handleTraceroute() {
|
||||
if (!node) return;
|
||||
|
||||
toast({
|
||||
title: t("toast.sendingTraceroute.title", { ns: "ui" }),
|
||||
});
|
||||
connection?.traceRoute(node.num).then(() =>
|
||||
toast({
|
||||
title: t("toast.tracerouteSent.title", { ns: "ui" }),
|
||||
})
|
||||
);
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
function handleNodeRemove() {
|
||||
if (!node) return;
|
||||
|
||||
setNodeNumToBeRemoved(node?.num);
|
||||
setDialogOpen("nodeRemoval", true);
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
function handleToggleFavorite() {
|
||||
if (!node) return;
|
||||
|
||||
updateFavorite({ nodeNum: node.num, isFavorite: !isFavoriteState });
|
||||
setIsFavoriteState(!isFavoriteState);
|
||||
}
|
||||
|
||||
function handleToggleIgnored() {
|
||||
if (!node) return;
|
||||
|
||||
updateIgnored({ nodeNum: node.num, isIgnored: !isIgnoredState });
|
||||
setIsIgnoredState(!isIgnoredState);
|
||||
}
|
||||
|
||||
const deviceMetricsMap = [
|
||||
{
|
||||
key: "airUtilTx",
|
||||
label: "Air TX utilization",
|
||||
value: device.deviceMetrics?.airUtilTx,
|
||||
label: t("nodeDetails.airTxUtilization"),
|
||||
value: node.deviceMetrics?.airUtilTx,
|
||||
format: (val: number) => `${val.toFixed(2)}%`,
|
||||
},
|
||||
{
|
||||
key: "channelUtilization",
|
||||
label: "Channel utilization",
|
||||
value: device.deviceMetrics?.channelUtilization,
|
||||
label: t("nodeDetails.channelUtilization"),
|
||||
value: node.deviceMetrics?.channelUtilization,
|
||||
format: (val: number) => `${val.toFixed(2)}%`,
|
||||
},
|
||||
{
|
||||
key: "batteryLevel",
|
||||
label: "Battery level",
|
||||
value: device.deviceMetrics?.batteryLevel,
|
||||
label: t("nodeDetails.batteryLevel"),
|
||||
value: node.deviceMetrics?.batteryLevel,
|
||||
format: (val: number) => `${val.toFixed(2)}%`,
|
||||
},
|
||||
{
|
||||
key: "voltage",
|
||||
label: "Voltage",
|
||||
value: device.deviceMetrics?.voltage,
|
||||
label: t("nodeDetails.voltage"),
|
||||
value: node.deviceMetrics?.voltage,
|
||||
format: (val: number) => `${val.toFixed(2)}V`,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent >
|
||||
<DialogContent aria-describedby={undefined}>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Node Details for {device.user?.longName ?? "UNKNOWN"} (
|
||||
{device.user?.shortName ?? "UNK"})
|
||||
{t("nodeDetails.title", {
|
||||
identifier: `${node.user?.longName ?? t("unknown.shortName")} (${
|
||||
node.user?.shortName ?? t("unknown.shortName")
|
||||
})`,
|
||||
})}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<div className="w-full">
|
||||
<div className="flex flex-col">
|
||||
<DeviceImage
|
||||
className="w-32 h-32 mx-auto rounded-lg border-4 border-slate-200 dark:border-slate-800"
|
||||
deviceType={
|
||||
Protobuf.Mesh.HardwareModel[device.user?.hwModel ?? 0]
|
||||
}
|
||||
/>
|
||||
<div className="bg-slate-100 text-slate-900 dark:text-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
|
||||
<p className="text-lg font-semibold">Details:</p>
|
||||
<p>
|
||||
Hardware:{" "}
|
||||
{Protobuf.Mesh.HardwareModel[device.user?.hwModel ?? 0]}
|
||||
</p>
|
||||
<p>Node Number: {device.num}</p>
|
||||
<p>Node Hex: !{numberToHexUnpadded(device.num)}</p>
|
||||
<p>
|
||||
Role:{" "}
|
||||
{
|
||||
Protobuf.Config.Config_DeviceConfig_Role[
|
||||
device.user?.role ?? 0
|
||||
]
|
||||
}
|
||||
</p>
|
||||
<p>
|
||||
Last Heard:{" "}
|
||||
{device.lastHeard === 0 ? "Never" : <TimeAgo timestamp={device.lastHeard * 1000} />}
|
||||
<div className="flex flex-row flex-wrap space-y-1">
|
||||
<Button
|
||||
className="mr-1"
|
||||
name="message"
|
||||
onClick={handleDirectMessage}
|
||||
>
|
||||
<MessageSquareIcon className="mr-2" />
|
||||
{t("nodeDetails.message")}
|
||||
</Button>
|
||||
<Button
|
||||
className="mr-1"
|
||||
name="traceRoute"
|
||||
onClick={handleTraceroute}
|
||||
>
|
||||
<WaypointsIcon className="mr-2" />
|
||||
{t("nodeDetails.traceRoute")}
|
||||
</Button>
|
||||
<Button className="mr-1" onClick={handleToggleFavorite}>
|
||||
<StarIcon
|
||||
className={cn(
|
||||
isFavoriteState ? " fill-yellow-400 stroke-yellow-400" : "",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
<div className="flex flex-1 justify-start"></div>
|
||||
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className={cn(
|
||||
"flex justify-end mr-1 text-white",
|
||||
isIgnoredState
|
||||
? "bg-red-500 dark:bg-red-500 hover:bg-red-600 hover:dark:bg-red-600 text-white dark:text-white"
|
||||
: "",
|
||||
)}
|
||||
onClick={handleToggleIgnored}
|
||||
>
|
||||
{isIgnoredState ? <BellIcon /> : <BellOffIcon />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs">
|
||||
{isIgnoredState
|
||||
? t("nodeDetails.unignoreNode")
|
||||
: t("nodeDetails.ignoreNode")}
|
||||
<TooltipArrow className="fill-slate-800 dark:fill-slate-600" />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="flex justify-end"
|
||||
onClick={handleNodeRemove}
|
||||
>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs">
|
||||
{t("nodeDetails.removeNode")}
|
||||
<TooltipArrow className="fill-slate-800 dark:fill-slate-600" />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
<Separator className="mt-5 mb-2" />
|
||||
|
||||
<div className="flex flex-col flex-wrap space-x-1 space-y-1">
|
||||
<div className="flex flex-row space-x-2">
|
||||
<div className="w-full bg-slate-100 text-slate-900 dark:text-slate-100 dark:bg-slate-800 p-3 rounded-lg">
|
||||
<p className="text-lg font-semibold">
|
||||
{t("nodeDetails.details")}
|
||||
</p>
|
||||
<p>{t("nodeDetails.nodeNumber")}{node.num}</p>
|
||||
<p>
|
||||
{t("nodeDetails.nodeHexPrefix")}
|
||||
{numberToHexUnpadded(node.num)}
|
||||
</p>
|
||||
<p>
|
||||
{t("nodeDetails.role")}
|
||||
{Protobuf.Config.Config_DeviceConfig_Role[
|
||||
node.user?.role ?? 0
|
||||
].replace(/_/g, " ")}
|
||||
</p>
|
||||
<p>
|
||||
{t("nodeDetails.lastHeard")}
|
||||
{node.lastHeard === 0
|
||||
? t("nodesTable.lastHeardStatus.never", { ns: "nodes" })
|
||||
: <TimeAgo timestamp={node.lastHeard * 1000} />}
|
||||
</p>
|
||||
<p>
|
||||
{t("nodeDetails.hardware")}
|
||||
{(Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0] ??
|
||||
t("unknown.shortName"))
|
||||
.replace(/_/g, " ")}
|
||||
</p>
|
||||
</div>
|
||||
<DeviceImage
|
||||
className="h-45 w-45 p-2 rounded-lg border-4 border-slate-200 dark:border-slate-800"
|
||||
deviceType={Protobuf.Mesh
|
||||
.HardwareModel[node.user?.hwModel ?? 0]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-slate-900 dark:text-slate-100 bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
|
||||
<p className="text-lg font-semibold">
|
||||
{t("nodeDetails.position")}
|
||||
</p>
|
||||
|
||||
{node.position
|
||||
? (
|
||||
<>
|
||||
{node.position.latitudeI &&
|
||||
node.position.longitudeI && (
|
||||
<p>
|
||||
{t("locationResponse.coordinates")}
|
||||
<a
|
||||
className="text-blue-500 dark:text-blue-400"
|
||||
href={`https://www.openstreetmap.org/?mlat=${
|
||||
node.position.latitudeI / 1e7
|
||||
}&mlon=${node.position.longitudeI / 1e7}&layers=N`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{node.position.latitudeI / 1e7},{" "}
|
||||
{node.position.longitudeI / 1e7}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
{node.position.altitude && (
|
||||
<p>
|
||||
{t("locationResponse.altitude")}
|
||||
{node.position.altitude}
|
||||
{t("unit.meter.one")}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
: <p>{t("unknown.shortName")}</p>}
|
||||
<Button
|
||||
onClick={handleRequestPosition}
|
||||
name="requestPosition"
|
||||
className="mt-2"
|
||||
>
|
||||
<MapPinnedIcon className="mr-2" />
|
||||
{t("nodeDetails.requestPosition")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{device.position && (
|
||||
{node.deviceMetrics && (
|
||||
<div className="text-slate-900 dark:text-slate-100 bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
|
||||
<p className="text-lg font-semibold">Position:</p>
|
||||
{device.position.latitudeI && device.position.longitudeI && (
|
||||
<p>
|
||||
Coordinates:{" "}
|
||||
<a
|
||||
className="text-blue-500 dark:text-blue-400"
|
||||
href={`https://www.openstreetmap.org/?mlat=${device.position.latitudeI / 1e7
|
||||
}&mlon=${device.position.longitudeI / 1e7
|
||||
}&layers=N`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{device.position.latitudeI / 1e7},{" "}
|
||||
{device.position.longitudeI / 1e7}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
{device.position.altitude && (
|
||||
<p>Altitude: {device.position.altitude}m</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{device.deviceMetrics && (
|
||||
<div className="text-slate-900 dark:text-slate-100 bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
|
||||
<p className="text-lg font-semibold text-slate-900 dark:text-slate-50">
|
||||
Device Metrics:
|
||||
<p className="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||
{t("nodeDetails.deviceMetrics")}
|
||||
</p>
|
||||
{deviceMetricsMap.map(
|
||||
(metric) =>
|
||||
@@ -140,17 +343,16 @@ export const NodeDetailsDialog = ({
|
||||
<p key={metric.key}>
|
||||
{metric.label}: {metric.format(metric.value)}
|
||||
</p>
|
||||
)
|
||||
),
|
||||
)}
|
||||
{device.deviceMetrics.uptimeSeconds && (
|
||||
{node.deviceMetrics.uptimeSeconds && (
|
||||
<p>
|
||||
Uptime:{" "}
|
||||
<Uptime seconds={device.deviceMetrics.uptimeSeconds} />
|
||||
{t("nodeDetails.uptime")}
|
||||
<Uptime seconds={node.deviceMetrics.uptimeSeconds} />
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
<div className="text-slate-900 dark:text-slate-100 w-full max-w-[464px] bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
|
||||
@@ -158,12 +360,12 @@ export const NodeDetailsDialog = ({
|
||||
<AccordionItem className="AccordionItem" value="item-1">
|
||||
<AccordionTrigger>
|
||||
<p className="text-lg font-semibold text-slate-900 dark:text-slate-50">
|
||||
All Raw Metrics:
|
||||
{t("nodeDetails.allRawMetrics")}
|
||||
</p>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="overflow-x-scroll">
|
||||
<pre className="text-xs w-full">
|
||||
{JSON.stringify(device, null, 2)}
|
||||
{JSON.stringify(node, null, 2)}
|
||||
</pre>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
import { toast } from "../../core/hooks/useToast.ts";
|
||||
import { useAppStore } from "../../core/stores/appStore.ts";
|
||||
import { useDevice } from "../../core/stores/deviceStore.ts";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../UI/Dialog.tsx";
|
||||
import type { Protobuf } from "@meshtastic/core";
|
||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
|
||||
import { Button } from "../UI/Button.tsx";
|
||||
|
||||
export interface NodeOptionsDialogProps {
|
||||
node: Protobuf.Mesh.NodeInfo | undefined;
|
||||
open: boolean;
|
||||
onOpenChange: () => void;
|
||||
}
|
||||
|
||||
export const NodeOptionsDialog = ({
|
||||
node,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: NodeOptionsDialogProps) => {
|
||||
const { setDialogOpen, connection, setActivePage } = useDevice();
|
||||
const {
|
||||
setNodeNumToBeRemoved,
|
||||
setNodeNumDetails,
|
||||
setChatType,
|
||||
setActiveChat,
|
||||
} = useAppStore();
|
||||
const longName = node?.user?.longName ??
|
||||
(node ? `!${numberToHexUnpadded(node?.num)}` : "Unknown");
|
||||
const shortName = node?.user?.shortName ??
|
||||
(node ? `${numberToHexUnpadded(node?.num).substring(0, 4)}` : "UNK");
|
||||
|
||||
function handleDirectMessage() {
|
||||
if (!node) return;
|
||||
setChatType("direct");
|
||||
setActiveChat(node.num);
|
||||
setActivePage("messages");
|
||||
}
|
||||
|
||||
function handleRequestPosition() {
|
||||
if (!node) return;
|
||||
toast({
|
||||
title: "Requesting position, please wait...",
|
||||
});
|
||||
connection?.requestPosition(node.num).then(() =>
|
||||
toast({
|
||||
title: "Position request sent.",
|
||||
})
|
||||
);
|
||||
onOpenChange();
|
||||
}
|
||||
|
||||
function handleTraceroute() {
|
||||
if (!node) return;
|
||||
toast({
|
||||
title: "Sending Traceroute, please wait...",
|
||||
});
|
||||
connection?.traceRoute(node.num).then(() =>
|
||||
toast({
|
||||
title: "Traceroute sent.",
|
||||
})
|
||||
);
|
||||
onOpenChange();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>{`${longName} (${shortName})`}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col space-y-1">
|
||||
<div>
|
||||
<Button onClick={handleDirectMessage}>Direct Message</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Button onClick={handleRequestPosition}>Request Position</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Button onClick={handleTraceroute}>Trace Route</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
key="remove"
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setNodeNumToBeRemoved(node.num);
|
||||
setDialogOpen("nodeRemoval", true);
|
||||
}}
|
||||
>
|
||||
<TrashIcon />
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setNodeNumDetails(node.num);
|
||||
setDialogOpen("nodeDetails", true);
|
||||
}}
|
||||
>
|
||||
More Details
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { fromByteArray } from "base64-js";
|
||||
import { DownloadIcon, PrinterIcon } from "lucide-react";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface PkiBackupDialogProps {
|
||||
open: boolean;
|
||||
@@ -22,7 +23,8 @@ export const PkiBackupDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: PkiBackupDialogProps) => {
|
||||
const { config, setDialogOpen } = useDevice();
|
||||
const { t } = useTranslation("dialog");
|
||||
const { config, setDialogOpen, getMyNode } = useDevice();
|
||||
const privateKey = config.security?.privateKey;
|
||||
const publicKey = config.security?.publicKey;
|
||||
|
||||
@@ -46,7 +48,12 @@ export const PkiBackupDialog = ({
|
||||
printWindow.document.write(`
|
||||
<html>
|
||||
<head>
|
||||
<title>=== MESHTASTIC KEYS ===</title>
|
||||
<title>${
|
||||
t("pkiBackup.header", {
|
||||
shortName: getMyNode()?.user?.shortName ?? t("unknown.shortName"),
|
||||
longName: getMyNode()?.user?.longName ?? t("unknown.longName"),
|
||||
})
|
||||
}</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; padding: 20px; }
|
||||
h1 { font-size: 18px; }
|
||||
@@ -54,14 +61,18 @@ export const PkiBackupDialog = ({
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>=== MESHTASTIC KEYS ===</h1>
|
||||
<br>
|
||||
<h2>Public Key:</h2>
|
||||
<h1>${
|
||||
t("pkiBackup.header", {
|
||||
shortName: getMyNode()?.user?.shortName ?? t("unknown.shortName"),
|
||||
longName: getMyNode()?.user?.longName ?? t("unknown.longName"),
|
||||
})
|
||||
}</h1>
|
||||
<h3>${t("pkiBackup.secureBackup")}</h3>
|
||||
<h3>${t("pkiBackup.publicKey")}</h3>
|
||||
<p>${decodeKeyData(publicKey)}</p>
|
||||
<h2>Private Key:</h2>
|
||||
<h3>${t("pkiBackup.privateKey")}</h3>
|
||||
<p>${decodeKeyData(privateKey)}</p>
|
||||
<br>
|
||||
<p>=== END OF KEYS ===</p>
|
||||
<p>${t("pkiBackup.footer")}</p>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
@@ -69,7 +80,7 @@ export const PkiBackupDialog = ({
|
||||
printWindow.print();
|
||||
closeDialog();
|
||||
}
|
||||
}, [decodeKeyData, privateKey, publicKey, closeDialog]);
|
||||
}, [decodeKeyData, privateKey, publicKey, closeDialog, t]);
|
||||
|
||||
const createDownloadKeyFile = React.useCallback(() => {
|
||||
if (!privateKey || !publicKey) return;
|
||||
@@ -78,12 +89,12 @@ export const PkiBackupDialog = ({
|
||||
const decodedPublicKey = decodeKeyData(publicKey);
|
||||
|
||||
const formattedContent = [
|
||||
"=== MESHTASTIC KEYS ===\n\n",
|
||||
"Private Key:\n",
|
||||
`${t("pkiBackup.header")}\n\n`,
|
||||
`${t("pkiBackup.privateKey")}\n`,
|
||||
decodedPrivateKey,
|
||||
"\n\nPublic Key:\n",
|
||||
`\n\n${t("pkiBackup.publicKey")}\n`,
|
||||
decodedPublicKey,
|
||||
"\n\n=== END OF KEYS ===",
|
||||
`\n\n${t("pkiBackup.footer")}`,
|
||||
].join("");
|
||||
|
||||
const blob = new Blob([formattedContent], { type: "text/plain" });
|
||||
@@ -91,43 +102,47 @@ export const PkiBackupDialog = ({
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = "meshtastic_keys.txt";
|
||||
link.download = t("pkiBackup.fileName", {
|
||||
shortName: getMyNode()?.user?.shortName ?? t("unknown.shortName"),
|
||||
longName: getMyNode()?.user?.longName ?? t("unknown.longName"),
|
||||
});
|
||||
|
||||
link.style.display = "none";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
closeDialog();
|
||||
URL.revokeObjectURL(url);
|
||||
}, [decodeKeyData, privateKey, publicKey, closeDialog]);
|
||||
}, [decodeKeyData, privateKey, publicKey, closeDialog, t]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>Backup Keys</DialogTitle>
|
||||
<DialogTitle>{t("pkiBackup.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Its important to backup your public and private keys and store your
|
||||
backup securely!
|
||||
{t("pkiBackup.secureBackup")}
|
||||
</DialogDescription>
|
||||
<DialogDescription>
|
||||
<span className="font-bold break-before-auto">
|
||||
If you lose your keys, you will need to reset your device.
|
||||
{t("pkiBackup.loseKeysWarning")}
|
||||
</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="mt-6">
|
||||
<Button
|
||||
variant="default"
|
||||
name="download"
|
||||
onClick={() => createDownloadKeyFile()}
|
||||
className=""
|
||||
>
|
||||
<DownloadIcon size={20} className="mr-2" />
|
||||
Download
|
||||
{t("button.download")}
|
||||
</Button>
|
||||
<Button variant="default" onClick={() => renderPrintWindow()}>
|
||||
<PrinterIcon size={20} className="mr-2" />
|
||||
Print
|
||||
{t("button.print")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -8,31 +8,53 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@components/UI/Dialog.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface PkiRegenerateDialogProps {
|
||||
text: {
|
||||
title: string;
|
||||
description: string;
|
||||
button: string;
|
||||
};
|
||||
open: boolean;
|
||||
onOpenChange: () => void;
|
||||
onSubmit: () => void;
|
||||
}
|
||||
|
||||
export const PkiRegenerateDialog = ({
|
||||
text = {
|
||||
title: "",
|
||||
description: "",
|
||||
button: "",
|
||||
},
|
||||
open,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: PkiRegenerateDialogProps) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
const dialogText = {
|
||||
title: text.title || t("pkiRegenerate.title"),
|
||||
description: text.description ||
|
||||
t("pkiRegenerate.description"),
|
||||
button: text.button || t("button.regenerate"),
|
||||
};
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>Regenerate Key pair?</DialogTitle>
|
||||
<DialogTitle>{dialogText.title}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to regenerate key pair?
|
||||
{dialogText.description}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="destructive" onClick={() => onSubmit()}>
|
||||
Regenerate
|
||||
<Button
|
||||
variant="destructive"
|
||||
name="regenerate"
|
||||
onClick={() => onSubmit()}
|
||||
>
|
||||
{dialogText.button}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -13,9 +13,9 @@ import { Input } from "@components/UI/Input.tsx";
|
||||
import { Label } from "@components/UI/Label.tsx";
|
||||
import { Protobuf, type Types } from "@meshtastic/core";
|
||||
import { fromByteArray } from "base64-js";
|
||||
import { ClipboardIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { QRCode } from "react-qrcode-logo";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface QRDialogProps {
|
||||
open: boolean;
|
||||
@@ -30,6 +30,7 @@ export const QRDialog = ({
|
||||
loraConfig,
|
||||
channels,
|
||||
}: QRDialogProps) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
const [selectedChannels, setSelectedChannels] = useState<number[]>([0]);
|
||||
const [qrCodeUrl, setQrCodeUrl] = useState<string>("");
|
||||
const [qrCodeAdd, setQrCodeAdd] = useState<boolean>();
|
||||
@@ -65,9 +66,9 @@ export const QRDialog = ({
|
||||
<DialogContent>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>Generate QR Code</DialogTitle>
|
||||
<DialogTitle>{t("qr.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
The current LoRa configuration will also be shared.
|
||||
{t("qr.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
@@ -79,13 +80,18 @@ export const QRDialog = ({
|
||||
{channel.settings?.name.length
|
||||
? channel.settings.name
|
||||
: channel.role === Protobuf.Channel.Channel_Role.PRIMARY
|
||||
? "Primary"
|
||||
: `Channel: ${channel.index}`}
|
||||
? t("page.broadcastLabel", { ns: "channels" })
|
||||
: `${
|
||||
t("page.channelIndex", {
|
||||
ns: "channels",
|
||||
index: channel.index,
|
||||
})
|
||||
}${channel.index}`}
|
||||
</Label>
|
||||
<Checkbox
|
||||
key={channel.index}
|
||||
checked={selectedChannels.includes(channel.index)}
|
||||
onCheckedChange={() => {
|
||||
onChange={() => {
|
||||
if (selectedChannels.includes(channel.index)) {
|
||||
setSelectedChannels(
|
||||
selectedChannels.filter((c) =>
|
||||
@@ -108,38 +114,35 @@ export const QRDialog = ({
|
||||
<div className="flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
className={`border-slate-900 border-t border-l border-b rounded-l h-10 px-7 py-2 text-sm font-medium focus:outline-hidden focus:ring-2 focus:ring-offset-2 ${qrCodeAdd
|
||||
? "focus:ring-green-800 bg-green-800 text-white"
|
||||
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
|
||||
}`}
|
||||
className={`border-slate-900 border-t border-l border-b rounded-l h-10 px-7 py-2 text-sm font-medium focus:outline-hidden focus:ring-2 focus:ring-offset-2 ${
|
||||
qrCodeAdd
|
||||
? "focus:ring-green-800 bg-green-800 text-white"
|
||||
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
|
||||
}`}
|
||||
name="addChannels"
|
||||
onClick={() => setQrCodeAdd(true)}
|
||||
>
|
||||
Add Channels
|
||||
{t("qr.addChannels")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`border-slate-900 border-t border-r border-b rounded-r h-10 px-4 py-2 text-sm font-medium focus:outline-hidden focus:ring-2 focus:ring-offset-2 ${!qrCodeAdd
|
||||
? "focus:ring-green-800 bg-green-800 text-white"
|
||||
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
|
||||
}`}
|
||||
className={`border-slate-900 border-t border-r border-b rounded-r h-10 px-4 py-2 text-sm font-medium focus:outline-hidden focus:ring-2 focus:ring-offset-2 ${
|
||||
!qrCodeAdd
|
||||
? "focus:ring-green-800 bg-green-800 text-white"
|
||||
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
|
||||
}`}
|
||||
name="replaceChannels"
|
||||
onClick={() => setQrCodeAdd(false)}
|
||||
>
|
||||
Replace Channels
|
||||
{t("qr.replaceChannels")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Label>Sharable URL</Label>
|
||||
<Label>{t("qr.sharableUrl")}</Label>
|
||||
<Input
|
||||
value={qrCodeUrl}
|
||||
disabled
|
||||
className="dark:text-slate-900"
|
||||
action={{
|
||||
icon: ClipboardIcon,
|
||||
onClick() {
|
||||
void navigator.clipboard.writeText(qrCodeUrl);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
} from "@components/UI/Dialog.tsx";
|
||||
import { Input } from "@components/UI/Input.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { ClockIcon, RefreshCwIcon } from "lucide-react";
|
||||
import { RefreshCwIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState } from "react";
|
||||
|
||||
export interface RebootDialogProps {
|
||||
@@ -21,6 +22,7 @@ export const RebootDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: RebootDialogProps) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
const { connection } = useDevice();
|
||||
|
||||
const [time, setTime] = useState<number>(5);
|
||||
@@ -30,9 +32,11 @@ export const RebootDialog = ({
|
||||
<DialogContent>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>Schedule Reboot</DialogTitle>
|
||||
<DialogTitle>
|
||||
{t("reboot.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Reboot the connected node after x minutes.
|
||||
{t("reboot.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex gap-2 p-4">
|
||||
@@ -41,21 +45,16 @@ export const RebootDialog = ({
|
||||
className="dark:text-slate-900"
|
||||
value={time}
|
||||
onChange={(e) => setTime(Number.parseInt(e.target.value))}
|
||||
action={{
|
||||
icon: ClockIcon,
|
||||
onClick() {
|
||||
connection?.reboot(time * 60).then(() => onOpenChange(false));
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
className="w-24"
|
||||
name="now"
|
||||
onClick={() => {
|
||||
connection?.reboot(2).then(() => onOpenChange(false));
|
||||
}}
|
||||
>
|
||||
<RefreshCwIcon className="mr-2" size={16} />
|
||||
Now
|
||||
{t("button.now")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,48 +1,69 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { RebootOTADialog } from './RebootOTADialog.tsx';
|
||||
import { ReactNode } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { RebootOTADialog } from "./RebootOTADialog.tsx";
|
||||
import {
|
||||
ButtonHTMLAttributes,
|
||||
ClassAttributes,
|
||||
InputHTMLAttributes,
|
||||
ReactNode,
|
||||
} from "react";
|
||||
import { JSX } from "react/jsx-runtime";
|
||||
|
||||
const rebootOtaMock = vi.fn();
|
||||
let mockConnection: { rebootOta: (delay: number) => void } | undefined = {
|
||||
rebootOta: rebootOtaMock,
|
||||
};
|
||||
|
||||
vi.mock('@core/stores/deviceStore.ts', () => ({
|
||||
vi.mock("@core/stores/deviceStore.ts", () => ({
|
||||
useDevice: () => ({
|
||||
connection: mockConnection,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@components/UI/Button.tsx', async () => {
|
||||
const actual = await vi.importActual('@components/UI/Button.tsx');
|
||||
vi.mock("@components/UI/Button.tsx", async () => {
|
||||
const actual = await vi.importActual("@components/UI/Button.tsx");
|
||||
return {
|
||||
...actual,
|
||||
Button: (props: any) => <button {...props} />,
|
||||
Button: (
|
||||
props:
|
||||
& JSX.IntrinsicAttributes
|
||||
& ClassAttributes<HTMLButtonElement>
|
||||
& ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
) => <button {...props} />,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@components/UI/Input.tsx', async () => {
|
||||
const actual = await vi.importActual('@components/UI/Input.tsx');
|
||||
vi.mock("@components/UI/Input.tsx", async () => {
|
||||
const actual = await vi.importActual("@components/UI/Input.tsx");
|
||||
return {
|
||||
...actual,
|
||||
Input: (props: any) => <input {...props} />,
|
||||
Input: (
|
||||
props:
|
||||
& JSX.IntrinsicAttributes
|
||||
& ClassAttributes<HTMLInputElement>
|
||||
& InputHTMLAttributes<HTMLInputElement>,
|
||||
) => <input {...props} />,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@components/UI/Dialog.tsx', () => {
|
||||
vi.mock("@components/UI/Dialog.tsx", () => {
|
||||
return {
|
||||
Dialog: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogContent: ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DialogHeader: ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DialogTitle: ({ children }: { children: ReactNode }) => <h1>{children}</h1>,
|
||||
DialogDescription: ({ children }: { children: ReactNode }) => <p>{children}</p>,
|
||||
DialogDescription: ({ children }: { children: ReactNode }) => (
|
||||
<p>{children}</p>
|
||||
),
|
||||
DialogClose: () => null,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
describe('RebootOTADialog', () => {
|
||||
describe("RebootOTADialog", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
rebootOtaMock.mockClear();
|
||||
@@ -52,22 +73,23 @@ describe('RebootOTADialog', () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders dialog with default input value', () => {
|
||||
render(<RebootOTADialog open={true} onOpenChange={() => { }} />);
|
||||
it("renders dialog with default input value", () => {
|
||||
render(<RebootOTADialog open onOpenChange={() => {}} />);
|
||||
expect(screen.getByPlaceholderText(/enter delay/i)).toHaveValue(5);
|
||||
expect(screen.getByText(/schedule reboot/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: /schedule reboot/i, level: 1 }))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByText(/reboot to ota mode now/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('schedules a reboot with delay and calls rebootOta', async () => {
|
||||
it("schedules a reboot with delay and calls rebootOta", async () => {
|
||||
const onOpenChangeMock = vi.fn();
|
||||
render(<RebootOTADialog open={true} onOpenChange={onOpenChangeMock} />);
|
||||
render(<RebootOTADialog open onOpenChange={onOpenChangeMock} />);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/enter delay/i), {
|
||||
target: { value: '3' },
|
||||
target: { value: "3" },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText(/schedule reboot/i));
|
||||
fireEvent.click(screen.getByTestId("scheduleRebootBtn"));
|
||||
|
||||
expect(screen.getByText(/reboot has been scheduled/i)).toBeInTheDocument();
|
||||
|
||||
@@ -79,9 +101,9 @@ describe('RebootOTADialog', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('triggers an instant reboot', async () => {
|
||||
it("triggers an instant reboot", async () => {
|
||||
const onOpenChangeMock = vi.fn();
|
||||
render(<RebootOTADialog open={true} onOpenChange={onOpenChangeMock} />);
|
||||
render(<RebootOTADialog open onOpenChange={onOpenChangeMock} />);
|
||||
|
||||
fireEvent.click(screen.getByText(/reboot to ota mode now/i));
|
||||
|
||||
@@ -91,15 +113,14 @@ describe('RebootOTADialog', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call reboot if connection is undefined', async () => {
|
||||
it("does not call reboot if connection is undefined", async () => {
|
||||
const onOpenChangeMock = vi.fn();
|
||||
|
||||
// simulate no connection
|
||||
mockConnection = undefined;
|
||||
|
||||
render(<RebootOTADialog open={true} onOpenChange={onOpenChangeMock} />);
|
||||
render(<RebootOTADialog open onOpenChange={onOpenChangeMock} />);
|
||||
|
||||
fireEvent.click(screen.getByText(/schedule reboot/i));
|
||||
fireEvent.click(screen.getByTestId("scheduleRebootBtn"));
|
||||
vi.advanceTimersByTime(5000);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -107,8 +128,6 @@ describe('RebootOTADialog', () => {
|
||||
expect(onOpenChangeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// reset connection for other tests
|
||||
mockConnection = { rebootOta: rebootOtaMock };
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "@components/UI/Dialog.tsx";
|
||||
import { Input } from "@components/UI/Input.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface RebootOTADialogProps {
|
||||
open: boolean;
|
||||
@@ -19,7 +20,10 @@ export interface RebootOTADialogProps {
|
||||
|
||||
const DEFAULT_REBOOT_DELAY = 5; // seconds
|
||||
|
||||
export const RebootOTADialog = ({ open, onOpenChange }: RebootOTADialogProps) => {
|
||||
export const RebootOTADialog = (
|
||||
{ open, onOpenChange }: RebootOTADialogProps,
|
||||
) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
const { connection } = useDevice();
|
||||
const [time, setTime] = useState<number>(DEFAULT_REBOOT_DELAY);
|
||||
const [isScheduled, setIsScheduled] = useState(false);
|
||||
@@ -28,8 +32,8 @@ export const RebootOTADialog = ({ open, onOpenChange }: RebootOTADialogProps) =>
|
||||
const handleSetTime = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!e.target.validity.valid) {
|
||||
e.preventDefault();
|
||||
return
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const val = e.target.value;
|
||||
setInputValue(val);
|
||||
@@ -48,7 +52,6 @@ export const RebootOTADialog = ({ open, onOpenChange }: RebootOTADialogProps) =>
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
console.log("Rebooting...");
|
||||
resolve();
|
||||
}, delay * 1000);
|
||||
}).finally(() => {
|
||||
@@ -71,9 +74,11 @@ export const RebootOTADialog = ({ open, onOpenChange }: RebootOTADialogProps) =>
|
||||
<DialogContent>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>Reboot to OTA Mode</DialogTitle>
|
||||
<DialogTitle>
|
||||
{t("rebootOta.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Reboot the connected node after a delay into OTA (Over-the-Air) mode.
|
||||
{t("rebootOta.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -85,20 +90,27 @@ export const RebootOTADialog = ({ open, onOpenChange }: RebootOTADialogProps) =>
|
||||
className="dark:text-slate-900 appearance-none"
|
||||
value={inputValue}
|
||||
onChange={handleSetTime}
|
||||
placeholder="Enter delay (sec)"
|
||||
placeholder={t("rebootOta.enterDelay")}
|
||||
/>
|
||||
<Button onClick={() => handleRebootWithTimeout()} className="w-9/12">
|
||||
<Button
|
||||
onClick={() => handleRebootWithTimeout()}
|
||||
data-testid="scheduleRebootBtn"
|
||||
className="w-9/12"
|
||||
>
|
||||
<ClockIcon className="mr-2" size={18} />
|
||||
{isScheduled ? 'Reboot has been scheduled' : 'Schedule Reboot'}
|
||||
{isScheduled ? t("rebootOta.scheduled") : t("rebootOta.title")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button variant="destructive" onClick={() => handleInstantReboot()}>
|
||||
<Button
|
||||
variant="destructive"
|
||||
name="rebootNow"
|
||||
onClick={() => handleInstantReboot()}
|
||||
>
|
||||
<RefreshCwIcon className="mr-2" size={16} />
|
||||
Reboot to OTA Mode Now
|
||||
{t("button.rebootOtaNow")}
|
||||
</Button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,55 +1,49 @@
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, Mock, vi } from "vitest";
|
||||
import { RefreshKeysDialog } from "./RefreshKeysDialog";
|
||||
import { render } from "@testing-library/react";
|
||||
import { DeviceContext, useDeviceStore } from "@core/stores/deviceStore.ts";
|
||||
import { RefreshKeysDialog } from "./RefreshKeysDialog.tsx";
|
||||
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
|
||||
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
|
||||
vi.mock("./useRefreshKeysDialog.ts", () => ({
|
||||
useRefreshKeysDialog: vi.fn(),
|
||||
}));
|
||||
vi.mock("@core/stores/messageStore");
|
||||
vi.mock("./useRefreshKeysDialog");
|
||||
|
||||
describe("RefreshKeysDialog Component", () => {
|
||||
let handleCloseDialogMock: Mock;
|
||||
let handleNodeRemoveMock: Mock;
|
||||
let onOpenChangeMock: Mock;
|
||||
const mockUseMessageStore = vi.mocked(useMessageStore);
|
||||
const mockUseRefreshKeysDialog = vi.mocked(useRefreshKeysDialog);
|
||||
|
||||
beforeEach(() => {
|
||||
handleCloseDialogMock = vi.fn();
|
||||
handleNodeRemoveMock = vi.fn();
|
||||
onOpenChangeMock = vi.fn();
|
||||
const getInitialState = () =>
|
||||
useDeviceStore.getInitialState?.() ??
|
||||
{ devices: new Map(), remoteDevices: new Map() };
|
||||
|
||||
(useRefreshKeysDialog as Mock).mockReturnValue({
|
||||
handleCloseDialog: handleCloseDialogMock,
|
||||
handleNodeRemove: handleNodeRemoveMock,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the dialog with correct content", () => {
|
||||
render(<RefreshKeysDialog open={true} onOpenChange={onOpenChangeMock} />);
|
||||
expect(screen.getByText("Keys Mismatch")).toBeInTheDocument();
|
||||
expect(screen.getByText("Request New Keys")).toBeInTheDocument();
|
||||
expect(screen.getByText("Dismiss")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls handleNodeRemove when 'Request New Keys' button is clicked", () => {
|
||||
render(<RefreshKeysDialog open={true} onOpenChange={onOpenChangeMock} />);
|
||||
fireEvent.click(screen.getByText("Request New Keys"));
|
||||
expect(handleNodeRemoveMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls handleCloseDialog when 'Dismiss' button is clicked", () => {
|
||||
render(<RefreshKeysDialog open={true} onOpenChange={onOpenChangeMock} />);
|
||||
fireEvent.click(screen.getByText("Dismiss"));
|
||||
expect(handleCloseDialogMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onOpenChange when dialog close button is clicked", () => {
|
||||
render(<RefreshKeysDialog open={true} onOpenChange={onOpenChangeMock} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /close/i }));
|
||||
expect(handleCloseDialogMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not render when open is false", () => {
|
||||
render(<RefreshKeysDialog open={false} onOpenChange={onOpenChangeMock} />);
|
||||
expect(screen.queryByText("Keys Mismatch")).not.toBeInTheDocument();
|
||||
});
|
||||
beforeEach(() => {
|
||||
useDeviceStore.setState(getInitialState(), true);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("does not render dialog if no error exists for active chat", () => {
|
||||
const deviceId = 1;
|
||||
const activeChatNum = 54321;
|
||||
|
||||
useDeviceStore.getState().addDevice(deviceId);
|
||||
|
||||
const currentDeviceState = useDeviceStore.getState().getDevice(deviceId);
|
||||
if (!currentDeviceState) throw new Error("Device not found");
|
||||
|
||||
mockUseMessageStore.mockReturnValue({ activeChat: activeChatNum });
|
||||
mockUseRefreshKeysDialog.mockReturnValue({
|
||||
handleCloseDialog: vi.fn(),
|
||||
handleNodeRemove: vi.fn(),
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<DeviceContext.Provider value={currentDeviceState}>
|
||||
<RefreshKeysDialog open onOpenChange={vi.fn()} />
|
||||
</DeviceContext.Provider>,
|
||||
);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
@@ -8,54 +8,88 @@ import {
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import { LockKeyholeOpenIcon } from "lucide-react";
|
||||
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
|
||||
|
||||
export interface RefreshKeysDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const RefreshKeysDialog = ({ open, onOpenChange }: RefreshKeysDialogProps) => {
|
||||
|
||||
export const RefreshKeysDialog = (
|
||||
{ open, onOpenChange }: RefreshKeysDialogProps,
|
||||
) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
const { activeChat } = useMessageStore();
|
||||
const { nodeErrors, getNode } = useDevice();
|
||||
const { handleCloseDialog, handleNodeRemove } = useRefreshKeysDialog();
|
||||
|
||||
const nodeErrorNum = nodeErrors.get(activeChat);
|
||||
|
||||
if (!nodeErrorNum) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nodeWithError = getNode(nodeErrorNum.node);
|
||||
|
||||
const text = {
|
||||
title: t("refreshKeys.title", {
|
||||
identifier: nodeWithError?.user?.longName ?? "",
|
||||
}),
|
||||
description: `${t("refreshKeys.description.unableToSendDmPrefix")}${
|
||||
nodeWithError?.user?.longName ?? ""
|
||||
} (${nodeWithError?.user?.shortName ?? ""})${
|
||||
t("refreshKeys.description.keyMismatchReasonSuffix")
|
||||
}`,
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-8 flex flex-col gap-2">
|
||||
<DialogContent
|
||||
className="max-w-8 flex flex-col gap-2"
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
<DialogClose onClick={handleCloseDialog} />
|
||||
<DialogHeader>
|
||||
<DialogTitle>Keys Mismatch</DialogTitle>
|
||||
<DialogTitle>{text.title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
Your node is unable to send a direct message to this node. This is due to the remote node's current public key not matching the previously stored key for this node.
|
||||
{text.description}
|
||||
<ul className="mt-2">
|
||||
<li className="flex place-items-center gap-2 items-start">
|
||||
<div className="p-2 bg-slate-500 rounded-lg mt-1">
|
||||
<LockKeyholeOpenIcon size={30} className="text-white justify-center" />
|
||||
<LockKeyholeOpenIcon
|
||||
size={30}
|
||||
className="text-white justify-center"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div>
|
||||
<p className="font-bold mb-0.5">Accept New Keys</p>
|
||||
<p className="font-bold mb-0.5">
|
||||
{t("refreshKeys.label.acceptNewKeys")}
|
||||
</p>
|
||||
<p>
|
||||
This will remove the node from device and request new keys.
|
||||
{t("refreshKeys.description.acceptNewKeys")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="default"
|
||||
name="requestNewKeys"
|
||||
onClick={handleNodeRemove}
|
||||
className=""
|
||||
>
|
||||
Request New Keys
|
||||
{t("button.requestNewKeys")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
name="dismiss"
|
||||
onClick={handleCloseDialog}
|
||||
className=""
|
||||
>
|
||||
Dismiss
|
||||
{t("button.dismiss")}
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
{/* </DialogDescription> */}
|
||||
</DialogContent>
|
||||
</Dialog >
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
|
||||
import { beforeEach, describe, expect, it, Mock, vi } from "vitest";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
|
||||
vi.mock("@core/stores/appStore.ts", () => ({
|
||||
useAppStore: vi.fn(() => ({ activeChat: "chat-123" })),
|
||||
}));
|
||||
|
||||
vi.mock("@core/stores/deviceStore.ts", () => ({
|
||||
useDevice: vi.fn(() => ({
|
||||
removeNode: vi.fn(),
|
||||
setDialogOpen: vi.fn(),
|
||||
getNodeError: vi.fn(),
|
||||
clearNodeError: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("useRefreshKeysDialog Hook", () => {
|
||||
let removeNodeMock: Mock;
|
||||
let setDialogOpenMock: Mock;
|
||||
let getNodeErrorMock: Mock;
|
||||
let clearNodeErrorMock: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
removeNodeMock = vi.fn();
|
||||
setDialogOpenMock = vi.fn();
|
||||
getNodeErrorMock = vi.fn();
|
||||
clearNodeErrorMock = vi.fn();
|
||||
|
||||
(useDevice as Mock).mockReturnValue({
|
||||
removeNode: removeNodeMock,
|
||||
setDialogOpen: setDialogOpenMock,
|
||||
getNodeError: getNodeErrorMock,
|
||||
clearNodeError: clearNodeErrorMock,
|
||||
});
|
||||
});
|
||||
|
||||
it("handleNodeRemove should remove the node and update dialog if there is an error", () => {
|
||||
getNodeErrorMock.mockReturnValue({ node: "node-abc" });
|
||||
|
||||
const { result } = renderHook(() => useRefreshKeysDialog());
|
||||
|
||||
act(() => {
|
||||
result.current.handleNodeRemove();
|
||||
});
|
||||
|
||||
expect(getNodeErrorMock).toHaveBeenCalledWith("chat-123");
|
||||
expect(clearNodeErrorMock).toHaveBeenCalledWith("chat-123");
|
||||
expect(removeNodeMock).toHaveBeenCalledWith("node-abc");
|
||||
expect(setDialogOpenMock).toHaveBeenCalledWith("refreshKeys", false);
|
||||
});
|
||||
|
||||
it("handleNodeRemove should do nothing if there is no error", () => {
|
||||
getNodeErrorMock.mockReturnValue(undefined);
|
||||
|
||||
const { result } = renderHook(() => useRefreshKeysDialog());
|
||||
|
||||
act(() => {
|
||||
result.current.handleNodeRemove();
|
||||
});
|
||||
|
||||
expect(removeNodeMock).not.toHaveBeenCalled();
|
||||
expect(setDialogOpenMock).not.toHaveBeenCalled();
|
||||
expect(clearNodeErrorMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handleCloseDialog should close the dialog", () => {
|
||||
const { result } = renderHook(() => useRefreshKeysDialog());
|
||||
|
||||
act(() => {
|
||||
result.current.handleCloseDialog();
|
||||
});
|
||||
|
||||
expect(setDialogOpenMock).toHaveBeenCalledWith("refreshKeys", false);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,15 @@
|
||||
import { useCallback } from "react";
|
||||
import { useAppStore } from "@core/stores/appStore.ts";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { useMessageStore } from "@core/stores/messageStore/index.ts";
|
||||
|
||||
export function useRefreshKeysDialog() {
|
||||
const { removeNode, setDialogOpen, clearNodeError, getNodeError } = useDevice();
|
||||
const { activeChat } = useAppStore();
|
||||
const { removeNode, setDialogOpen, clearNodeError, getNodeError } =
|
||||
useDevice();
|
||||
const { activeChat } = useMessageStore();
|
||||
|
||||
const handleCloseDialog = useCallback(() => {
|
||||
setDialogOpen("refreshKeys", false);
|
||||
}, [setDialogOpen]);
|
||||
|
||||
const handleNodeRemove = useCallback(() => {
|
||||
const nodeWithError = getNodeError(activeChat);
|
||||
@@ -12,17 +17,12 @@ export function useRefreshKeysDialog() {
|
||||
return;
|
||||
}
|
||||
clearNodeError(activeChat);
|
||||
handleCloseDialog();;
|
||||
handleCloseDialog();
|
||||
return removeNode(nodeWithError?.node);
|
||||
}, [activeChat, clearNodeError, setDialogOpen, removeNode]);
|
||||
|
||||
const handleCloseDialog = useCallback(() => {
|
||||
setDialogOpen('refreshKeys', false);
|
||||
}, [setDialogOpen])
|
||||
}, [activeChat, clearNodeError, getNodeError, removeNode, handleCloseDialog]);
|
||||
|
||||
return {
|
||||
handleCloseDialog,
|
||||
handleNodeRemove
|
||||
handleNodeRemove,
|
||||
};
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@components/UI/Dialog.tsx";
|
||||
import { Label } from "@components/UI/Label.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface RemoveNodeDialogProps {
|
||||
open: boolean;
|
||||
@@ -21,7 +22,8 @@ export const RemoveNodeDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: RemoveNodeDialogProps) => {
|
||||
const { connection, nodes, removeNode } = useDevice();
|
||||
const { t } = useTranslation("dialog");
|
||||
const { connection, getNode, removeNode } = useDevice();
|
||||
const { nodeNumToBeRemoved } = useAppStore();
|
||||
|
||||
const onSubmit = () => {
|
||||
@@ -35,19 +37,23 @@ export const RemoveNodeDialog = ({
|
||||
<DialogContent>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>Remove Node?</DialogTitle>
|
||||
<DialogTitle>{t("removeNode.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to remove this Node?
|
||||
{t("removeNode.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="gap-4">
|
||||
<form onSubmit={onSubmit}>
|
||||
<Label>{nodes.get(nodeNumToBeRemoved)?.user?.longName}</Label>
|
||||
<Label>{getNode(nodeNumToBeRemoved)?.user?.longName}</Label>
|
||||
</form>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="destructive" onClick={() => onSubmit()}>
|
||||
Remove
|
||||
<Button
|
||||
variant="destructive"
|
||||
name="remove"
|
||||
onClick={() => onSubmit()}
|
||||
>
|
||||
{t("button.remove")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { Input } from "@components/UI/Input.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { ClockIcon, PowerIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState } from "react";
|
||||
|
||||
export interface ShutdownDialogProps {
|
||||
@@ -21,6 +22,7 @@ export const ShutdownDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: ShutdownDialogProps) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
const { connection } = useDevice();
|
||||
|
||||
const [time, setTime] = useState<number>(5);
|
||||
@@ -30,9 +32,11 @@ export const ShutdownDialog = ({
|
||||
<DialogContent>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>Schedule Shutdown</DialogTitle>
|
||||
<DialogTitle>
|
||||
{t("shutdown.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Turn off the connected node after x minutes.
|
||||
{t("shutdown.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -41,8 +45,7 @@ export const ShutdownDialog = ({
|
||||
type="number"
|
||||
value={time}
|
||||
onChange={(e) => setTime(Number.parseInt(e.target.value))}
|
||||
className="dark:text-slate-900"
|
||||
suffix="Minutes"
|
||||
suffix={t("unit.minute.plural")}
|
||||
/>
|
||||
<Button
|
||||
className="w-24"
|
||||
@@ -54,12 +57,13 @@ export const ShutdownDialog = ({
|
||||
</Button>
|
||||
<Button
|
||||
className="w-24"
|
||||
name="now"
|
||||
onClick={() => {
|
||||
connection?.shutdown(2).then(() => () => onOpenChange(false));
|
||||
}}
|
||||
>
|
||||
<PowerIcon className="mr-2" size={16} />
|
||||
Now
|
||||
{t("button.now")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useDevice } from "../../core/stores/deviceStore.ts";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
@@ -11,6 +11,7 @@ import type { Protobuf, Types } from "@meshtastic/core";
|
||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||
|
||||
import { TraceRoute } from "../PageComponents/Messages/TraceRoute.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface TracerouteResponseDialogProps {
|
||||
traceroute: Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery> | undefined;
|
||||
@@ -23,30 +24,43 @@ export const TracerouteResponseDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: TracerouteResponseDialogProps) => {
|
||||
const { nodes } = useDevice();
|
||||
const { t } = useTranslation("dialog");
|
||||
const { getNode } = useDevice();
|
||||
const route: number[] = traceroute?.data.route ?? [];
|
||||
const routeBack: number[] = traceroute?.data.routeBack ?? [];
|
||||
const snrTowards = traceroute?.data.snrTowards ?? [];
|
||||
const snrBack = traceroute?.data.snrBack ?? [];
|
||||
const from = nodes.get(traceroute?.from ?? 0);
|
||||
const longName = from?.user?.longName ??
|
||||
(from ? `!${numberToHexUnpadded(from?.num)}` : "Unknown");
|
||||
const shortName = from?.user?.shortName ??
|
||||
(from ? `${numberToHexUnpadded(from?.num).substring(0, 4)}` : "UNK");
|
||||
const to = nodes.get(traceroute?.to ?? 0);
|
||||
const snrTowards = (traceroute?.data.snrTowards ?? []).map((snr) => snr / 4);
|
||||
const snrBack = (traceroute?.data.snrBack ?? []).map((snr) => snr / 4);
|
||||
const from = getNode(traceroute?.from ?? 0);
|
||||
const fromLongName = from?.user?.longName ??
|
||||
(from ? `!${numberToHexUnpadded(from?.num)}` : t("unknown.shortName"));
|
||||
const fromShortName = from?.user?.shortName ??
|
||||
(from
|
||||
? `${numberToHexUnpadded(from?.num).substring(0, 4)}`
|
||||
: t("unknown.shortName"));
|
||||
|
||||
const toUser = getNode(traceroute?.to ?? 0);
|
||||
|
||||
if (!toUser || !from) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>{`Traceroute: ${longName} (${shortName})`}</DialogTitle>
|
||||
<DialogTitle>
|
||||
{t("tracerouteResponse.title", {
|
||||
identifier: `${fromLongName} (${fromShortName})`,
|
||||
})}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogDescription>
|
||||
<TraceRoute
|
||||
route={route}
|
||||
routeBack={routeBack}
|
||||
from={from}
|
||||
to={to}
|
||||
from={{ user: from.user }}
|
||||
to={{ user: toUser.user }}
|
||||
snrTowards={snrTowards}
|
||||
snrBack={snrBack}
|
||||
/>
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
// deno-lint-ignore-file
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx";
|
||||
import { eventBus } from "@core/utils/eventBus.ts";
|
||||
import { DeviceWrapper } from "@app/DeviceWrapper.tsx";
|
||||
|
||||
describe("UnsafeRolesDialog", () => {
|
||||
const mockDevice = {
|
||||
setDialogOpen: vi.fn(),
|
||||
};
|
||||
|
||||
const renderWithDeviceContext = (ui: any) => {
|
||||
return render(
|
||||
<DeviceWrapper device={mockDevice}>
|
||||
{ui}
|
||||
</DeviceWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
it("renders the dialog when open is true", () => {
|
||||
renderWithDeviceContext(<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />);
|
||||
|
||||
const dialog = screen.getByRole('dialog');
|
||||
expect(dialog).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(/I have read the/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/understand the implications/i)).toBeInTheDocument();
|
||||
|
||||
const links = screen.getAllByRole('link');
|
||||
expect(links).toHaveLength(2);
|
||||
expect(links[0]).toHaveTextContent('Device Role Documentation');
|
||||
expect(links[1]).toHaveTextContent('Choosing The Right Device Role');
|
||||
});
|
||||
|
||||
it("displays the correct links", () => {
|
||||
renderWithDeviceContext(<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />);
|
||||
|
||||
const docLink = screen.getByRole("link", { name: /Device Role Documentation/i });
|
||||
const blogLink = screen.getByRole("link", { name: /Choosing The Right Device Role/i });
|
||||
|
||||
expect(docLink).toHaveAttribute("href", "https://meshtastic.org/docs/configuration/radio/device/");
|
||||
expect(blogLink).toHaveAttribute("href", "https://meshtastic.org/blog/choosing-the-right-device-role/");
|
||||
});
|
||||
|
||||
it("does not allow confirmation until checkbox is checked", () => {
|
||||
renderWithDeviceContext(<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />);
|
||||
|
||||
const confirmButton = screen.getByRole("button", { name: /confirm/i });
|
||||
|
||||
expect(confirmButton).toBeDisabled();
|
||||
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
expect(confirmButton).toBeEnabled();
|
||||
});
|
||||
|
||||
it("emits the correct event when closing via close button", () => {
|
||||
const eventSpy = vi.spyOn(eventBus, "emit");
|
||||
renderWithDeviceContext(<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />);
|
||||
|
||||
const dismissButton = screen.getByRole("button", { name: /close/i });
|
||||
fireEvent.click(dismissButton);
|
||||
|
||||
expect(eventSpy).toHaveBeenCalledWith("dialog:unsafeRoles", { action: "dismiss" });
|
||||
});
|
||||
|
||||
it("emits the correct event when dismissing", () => {
|
||||
const eventSpy = vi.spyOn(eventBus, "emit");
|
||||
renderWithDeviceContext(<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />);
|
||||
|
||||
const dismissButton = screen.getByRole("button", { name: /dismiss/i });
|
||||
fireEvent.click(dismissButton);
|
||||
|
||||
expect(eventSpy).toHaveBeenCalledWith("dialog:unsafeRoles", { action: "dismiss" });
|
||||
});
|
||||
|
||||
it("emits the correct event when confirming", () => {
|
||||
const eventSpy = vi.spyOn(eventBus, "emit");
|
||||
renderWithDeviceContext(<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />);
|
||||
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
const confirmButton = screen.getByRole("button", { name: /confirm/i });
|
||||
|
||||
fireEvent.click(checkbox);
|
||||
fireEvent.click(confirmButton);
|
||||
|
||||
expect(eventSpy).toHaveBeenCalledWith("dialog:unsafeRoles", { action: "confirm" });
|
||||
});
|
||||
});
|
||||