refactor: switch to using Bun (#718)

This commit is contained in:
Dan Ditomaso
2025-07-16 22:30:10 -04:00
committed by GitHub
parent acfd7ebd2d
commit 704d06cfe7
401 changed files with 35294 additions and 39107 deletions
+1 -1
View File
@@ -9,4 +9,4 @@ if [ -f "$SIMPLE_GIT_HOOKS_RC" ]; then
. "$SIMPLE_GIT_HOOKS_RC"
fi
cd packages/web && deno task lint:fix && deno task format
# cd packages/web && bun run lint && bun run format
+14 -14
View File
@@ -16,29 +16,29 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Deno
uses: denoland/setup-deno@v2
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
deno-version: v2.x
bun-version: latest
- name: Install Dependencies
run: deno install
run: bun install
- name: Cache Deno dependencies
- name: Cache Bun dependencies
uses: actions/cache@v4
with:
path: |
~/.cache/deno
packages/web/deno.lock
key: ${{ runner.os }}-deno-${{ hashFiles('packages/web/deno.lock') }}
~/.bun/install/cache
packages/web/node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('packages/web/bun.lockb') }}
restore-keys: |
${{ runner.os }}-deno-
${{ runner.os }}-bun-
- name: Run linter
run: deno lint
# - name: Run linter
# run: bun run lint
- name: Check formatter
run: deno fmt --check
# - name: Check formatter
# run: bun run check
- name: Build Package
run: deno task --filter web build
run: bun run build
+12 -10
View File
@@ -15,18 +15,20 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Deno
uses: denoland/setup-deno@v2
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
deno-version: v2.x
bun-version: latest
- name: Cache Deno dependencies
- name: Cache Bun dependencies
uses: actions/cache@v4
with:
path: ${{ steps.setup-deno.outputs.deno-cache-path }}
key: deno-cache-${{ runner.os }}-${{ hashFiles('**/deno.lock') }}
path: |
~/.bun/install/cache
packages/web/node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('packages/web/bun.lockb') }}
restore-keys: |
deno-cache-${{ runner.os }}-*
${{ runner.os }}-bun-
# - name: Run tests
# working-directory: packages/web
@@ -34,15 +36,15 @@ jobs:
- name: Install Dependencies
working-directory: packages/web
run: deno install
run: bun install
- name: Build Package
working-directory: packages/web
run: deno task build
run: bun run build
- name: Package Output
working-directory: packages/web
run: deno task package
run: bun run package
- name: Archive compressed build
uses: actions/upload-artifact@v4
+16 -16
View File
@@ -13,34 +13,34 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Deno
uses: denoland/setup-deno@v2
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
deno-version: v2.x
bun-version: latest
- name: Install Dependencies
# Commands will run from 'packages/web'
working-directory: packages/web
run: deno install
run: bun install
- name: Cache Deno dependencies
- name: Cache Bun dependencies
uses: actions/cache@v4
with:
path: |
~/.cache/deno
packages/web/deno.lock
key: ${{ runner.os }}-deno-${{ hashFiles('packages/web/deno.lock') }}
~/.bun/install/cache
packages/web/node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('packages/web/bun.lockb') }}
restore-keys: |
${{ runner.os }}-deno-
${{ runner.os }}-bun-
- name: Run linter
working-directory: packages/web
run: deno lint
# - name: Run linter
# working-directory: packages/web
# run: bun run lint
- name: Check formatter
working-directory: packages/web
run: deno fmt --check
# - name: Check formatter
# working-directory: packages/web
# run: bun run check
- name: Build Package
working-directory: packages/web
run: deno task --filter web build
run: bun run build
+15 -7
View File
@@ -19,19 +19,27 @@ jobs:
with:
fetch-depth: 0
# --- Setup Deno ---
# --- Setup Bun ---
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
# --- Setup Deno (for NPM package building) ---
- name: Setup Deno
uses: denoland/setup-deno@v2
with:
deno-version: v2.x
- name: Cache Deno Dependencies
- name: Cache Bun Dependencies
uses: actions/cache@v4
with:
path: ~/.cache/deno
key: ${{ runner.os }}-deno-${{ hashFiles('**/deno.lock') }}
path: |
~/.bun/install/cache
packages/web/node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}
restore-keys: |
${{ runner.os }}-deno-
${{ runner.os }}-bun-
# --- Determine Changed Packages ---
- name: Get Changed Package Directories
@@ -98,12 +106,12 @@ jobs:
- name: Run Web App Tests
if: steps.web_changes.outputs.web_changed == 'true'
working-directory: packages/web
run: deno task test
run: bun run test
- name: Create Web App Release Archive
if: steps.web_changes.outputs.web_changed == 'true'
working-directory: packages/web
run: deno task package # Generates dist/build.tar
run: bun run package # Generates dist/build.tar
- name: Upload Web App Archive
if: steps.web_changes.outputs.web_changed == 'true'
+2 -5
View File
@@ -1,9 +1,6 @@
{
"deno.enable": true,
"deno.suggest.imports.autoDiscover": true,
"editor.formatOnSave": true,
"editor.defaultFormatter": "denoland.vscode-deno",
"[typescript]": {
"editor.defaultFormatter": "denoland.vscode-deno"
"editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit",
}
}
+15 -15
View File
@@ -43,7 +43,7 @@ All `Meshtastic JS` packages (core and transports) are published both to
This monorepo leverages the following technologies:
- **Runtime:** Deno
- **Runtime:** Bun
- **Web Client:** React.js
- **Styling:** Tailwind CSS
- **Bundling:** Vite
@@ -56,7 +56,7 @@ This monorepo leverages the following technologies:
### Prerequisites
You'll need to have [Deno](https://deno.com/) installed to work with this
You'll need to have [Bun](https://bun.sh/) installed to work with this
monorepo. Follow the installation instructions on their home page.
### Development Setup
@@ -68,7 +68,7 @@ monorepo. Follow the installation instructions on their home page.
```
2. **Install dependencies for all packages:**
```bash
deno task install:all
bun install
```
This command installs all necessary dependencies for all packages within the
monorepo.
@@ -80,7 +80,7 @@ monorepo. Follow the installation instructions on their home page.
To start the development server for the web client:
```bash
deno task --filter web dev
bun run --filter web dev
```
This will typically run the web client on http://localhost:3000 and requires a
@@ -89,11 +89,11 @@ Chromium browser
## Meshtastic JS Packages
While the js packages are primarily libraries, you can run their tests or
specific development scripts if defined within their deno.json files. For
specific development scripts if defined within their package.json files. For
example, to run tests for a specific package:
```bash
deno task --filter core test
bun run --filter core test
```
### Feedback
@@ -102,24 +102,24 @@ If you encounter any issues with nightly builds, please report them in our
[issues tracker](https://github.com/meshtastic/web/issues). Your feedback helps
improve the stability of future releases
### Why Deno?
### Why Bun?
Meshtastic Web uses Deno as its development platform for several compelling
Meshtastic Web uses Bun as its development platform for several compelling
reasons:
- **Built-in Security**: Deno's security-first approach requires explicit
permissions for file, network, and environment access, reducing vulnerability
risks.
- **Fast Performance**: Bun is built from the ground up for speed, offering
significantly faster package installation and bundling compared to other
JavaScript runtimes.
- **TypeScript Support**: Native TypeScript support without additional
configuration, enhancing code quality and developer experience.
- **Modern JavaScript**: First-class support for ESM imports, top-level await,
and other modern JavaScript features.
- **Simplified Tooling**: Built-in formatter, linter, test runner, and bundler
eliminate the need for multiple third-party tools.
- **All-in-One Tooling**: Built-in package manager, bundler, test runner, and
transpiler eliminate the need for multiple third-party tools.
- **Node.js Compatibility**: Drop-in replacement for Node.js with better
performance and built-in tooling.
- **Reproducible Builds**: Lockfile ensures consistent builds across all
environments.
- **Web Standard APIs**: Uses browser-compatible APIs, making code more portable
between server and client environments.
### Contributing
+1716
View File
File diff suppressed because it is too large Load Diff
-50
View File
@@ -1,50 +0,0 @@
{
"workspace": [
"./packages/web",
"./packages/core",
"./packages/transport-deno",
"./packages/transport-node",
"./packages/transport-http",
"./packages/transport-web-bluetooth",
"./packages/transport-web-serial"
],
"tasks": {
"build:npm": "deno run -A scripts/build_npm_package.ts"
},
"imports": {
"@bufbuild/protobuf": "npm:@bufbuild/protobuf@^2.2.3",
"@deno/dnt": "jsr:@deno/dnt@^0.42.1",
"@meshtastic/protobufs": "jsr:@meshtastic/protobufs@^2.7.0",
"@types/node": "npm:@types/node@^22.13.10",
"ste-simple-events": "npm:ste-simple-events@^3.0.11",
"tslog": "npm:tslog@^4.9.3"
},
"nodeModulesDir": "auto",
"lint": {
"exclude": [
"*routeTree.gen.ts",
"*.test.ts",
"*.test.tsx"
],
"report": "pretty"
},
"fmt": {
"exclude": [
"*routeTree.gen.ts",
"*.test.ts",
"*.test.tsx"
]
},
"unstable": [
"sloppy-imports"
],
"exclude": [
"node_modules",
"dist",
"npm",
"build",
"coverage",
"out",
".vscode-test"
]
}
Generated
-5419
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "meshtastic-web",
"version": "2.7.0-0",
"type": "module",
"description": "Meshtastic web client monorepo",
"license": "GPL-3.0-only",
"repository": {
"type": "git",
"url": "git+https://github.com/meshtastic/web.git"
},
"bugs": {
"url": "https://github.com/meshtastic/web/issues"
},
"homepage": "https://meshtastic.org",
"workspaces": ["packages/web"],
"scripts": {
"build:npm": "deno run -A scripts/build_npm_package.ts"
},
"devDependencies": {
"@biomejs/biome": "^1.8.3",
"bun": "^1.1.18",
"typescript": "^5.8.3"
}
}
+12 -35
View File
@@ -3,11 +3,11 @@ import { Logger } from "tslog";
import { create, fromBinary, toBinary } from "@bufbuild/protobuf";
import * as Protobuf from "@meshtastic/protobufs";
import { EventSystem, Queue, Xmodem } from "./utils/mod.ts";
import { decodePacket } from "./utils/transform/decodePacket.ts";
import { Constants } from "./constants.ts";
import type { Destination, PacketMetadata, Transport } from "./types.ts";
import { ChannelNumber, DeviceStatusEnum, Emitter } from "./types.ts";
import { EventSystem, Queue, Xmodem } from "./utils/mod.ts";
import { decodePacket } from "./utils/transform/decodePacket.ts";
export class MeshDevice {
public transport: Transport;
@@ -182,7 +182,8 @@ export class MeshDevice {
},
},
from: this.myNodeInfo.myNodeNum,
to: destination === "broadcast"
to:
destination === "broadcast"
? Constants.broadcastNum
: destination === "self"
? this.myNodeInfo.myNodeNum
@@ -262,10 +263,7 @@ export class MeshDevice {
public async setModuleConfig(
moduleConfig: Protobuf.ModuleConfig.ModuleConfig,
): Promise<number> {
this.log.debug(
Emitter[Emitter.SetModuleConfig],
"⚙️ Setting module config",
);
this.log.debug(Emitter[Emitter.SetModuleConfig], "⚙️ Setting module config");
const moduleConfigMessage = create(Protobuf.Admin.AdminMessageSchema, {
payloadVariant: {
@@ -351,10 +349,7 @@ export class MeshDevice {
* Triggers Device to enter DFU mode
*/
public async enterDfuMode(): Promise<number> {
this.log.debug(
Emitter[Emitter.EnterDfuMode],
"🔌 Entering DFU mode",
);
this.log.debug(Emitter[Emitter.EnterDfuMode], "🔌 Entering DFU mode");
const enterDfuModeRequest = create(Protobuf.Admin.AdminMessageSchema, {
payloadVariant: {
@@ -411,10 +406,7 @@ export class MeshDevice {
public async getConfig(
configType: Protobuf.Admin.AdminMessage_ConfigType,
): Promise<number> {
this.log.debug(
Emitter[Emitter.GetConfig],
"⚙️ Requesting config",
);
this.log.debug(Emitter[Emitter.GetConfig], "⚙️ Requesting config");
const getRadioRequestMessage = create(Protobuf.Admin.AdminMessageSchema, {
payloadVariant: {
@@ -457,10 +449,7 @@ export class MeshDevice {
/** Gets devices Owner */
public async getOwner(): Promise<number> {
this.log.debug(
Emitter[Emitter.GetOwner],
"👤 Requesting owner",
);
this.log.debug(Emitter[Emitter.GetOwner], "👤 Requesting owner");
const getOwnerRequestMessage = create(Protobuf.Admin.AdminMessageSchema, {
payloadVariant: {
@@ -572,10 +561,7 @@ export class MeshDevice {
* that no longer exist.
*/
public async resetNodes(): Promise<number> {
this.log.debug(
Emitter[Emitter.ResetNodes],
"📻 Resetting NodeDB",
);
this.log.debug(Emitter[Emitter.ResetNodes], "📻 Resetting NodeDB");
const resetNodes = create(Protobuf.Admin.AdminMessageSchema, {
payloadVariant: {
@@ -683,10 +669,7 @@ export class MeshDevice {
* Factory resets the current device
*/
public async factoryResetDevice(): Promise<number> {
this.log.debug(
Emitter[Emitter.FactoryReset],
"♻️ Factory resetting device",
);
this.log.debug(Emitter[Emitter.FactoryReset], "♻️ Factory resetting device");
const factoryReset = create(Protobuf.Admin.AdminMessageSchema, {
payloadVariant: {
@@ -706,10 +689,7 @@ export class MeshDevice {
* Factory resets the current config
*/
public async factoryResetConfig(): Promise<number> {
this.log.debug(
Emitter[Emitter.FactoryReset],
"♻️ Factory resetting config",
);
this.log.debug(Emitter[Emitter.FactoryReset], "♻️ Factory resetting config");
const factoryReset = create(Protobuf.Admin.AdminMessageSchema, {
payloadVariant: {
@@ -749,10 +729,7 @@ export class MeshDevice {
* Serial connection requires a heartbeat ping to stay connected, otherwise times out after 15 minutes
*/
public heartbeat(): Promise<number> {
this.log.debug(
Emitter[Emitter.Ping],
"❤️ Send heartbeat ping to radio",
);
this.log.debug(Emitter[Emitter.Ping], "❤️ Send heartbeat ping to radio");
const toRadio = create(Protobuf.Mesh.ToRadioSchema, {
payloadVariant: {
+50 -136
View File
@@ -1,5 +1,5 @@
import { SimpleEventDispatcher } from "ste-simple-events";
import type * as Protobuf from "@meshtastic/protobufs";
import { SimpleEventDispatcher } from "ste-simple-events";
import type { PacketMetadata } from "../types.ts";
import type * as Types from "../types.ts";
@@ -9,22 +9,16 @@ export class EventSystem {
*
* @event onLogEvent
*/
public readonly onLogEvent: SimpleEventDispatcher<
Types.LogEventPacket
> = new SimpleEventDispatcher<
Types.LogEventPacket
>();
public readonly onLogEvent: SimpleEventDispatcher<Types.LogEventPacket> =
new SimpleEventDispatcher<Types.LogEventPacket>();
/**
* Fires when a new FromRadio message has been received from the device
*
* @event onFromRadio
*/
public readonly onFromRadio: SimpleEventDispatcher<
Protobuf.Mesh.FromRadio
> = new SimpleEventDispatcher<
Protobuf.Mesh.FromRadio
>();
public readonly onFromRadio: SimpleEventDispatcher<Protobuf.Mesh.FromRadio> =
new SimpleEventDispatcher<Protobuf.Mesh.FromRadio>();
/**
* Fires when a new FromRadio message containing a Data packet has been
@@ -32,22 +26,16 @@ export class EventSystem {
*
* @event onMeshPacket
*/
public readonly onMeshPacket: SimpleEventDispatcher<
Protobuf.Mesh.MeshPacket
> = new SimpleEventDispatcher<
Protobuf.Mesh.MeshPacket
>();
public readonly onMeshPacket: SimpleEventDispatcher<Protobuf.Mesh.MeshPacket> =
new SimpleEventDispatcher<Protobuf.Mesh.MeshPacket>();
/**
* Fires when a new MyNodeInfo message has been received from the device
*
* @event onMyNodeInfo
*/
public readonly onMyNodeInfo: SimpleEventDispatcher<
Protobuf.Mesh.MyNodeInfo
> = new SimpleEventDispatcher<
Protobuf.Mesh.MyNodeInfo
>();
public readonly onMyNodeInfo: SimpleEventDispatcher<Protobuf.Mesh.MyNodeInfo> =
new SimpleEventDispatcher<Protobuf.Mesh.MyNodeInfo>();
/**
* Fires when a new MeshPacket message containing a NodeInfo packet has been
@@ -55,44 +43,32 @@ export class EventSystem {
*
* @event onNodeInfoPacket
*/
public readonly onNodeInfoPacket: SimpleEventDispatcher<
Protobuf.Mesh.NodeInfo
> = new SimpleEventDispatcher<
Protobuf.Mesh.NodeInfo
>();
public readonly onNodeInfoPacket: SimpleEventDispatcher<Protobuf.Mesh.NodeInfo> =
new SimpleEventDispatcher<Protobuf.Mesh.NodeInfo>();
/**
* Fires when a new Channel message is received
*
* @event onChannelPacket
*/
public readonly onChannelPacket: SimpleEventDispatcher<
Protobuf.Channel.Channel
> = new SimpleEventDispatcher<
Protobuf.Channel.Channel
>();
public readonly onChannelPacket: SimpleEventDispatcher<Protobuf.Channel.Channel> =
new SimpleEventDispatcher<Protobuf.Channel.Channel>();
/**
* Fires when a new Config message is received
*
* @event onConfigPacket
*/
public readonly onConfigPacket: SimpleEventDispatcher<
Protobuf.Config.Config
> = new SimpleEventDispatcher<
Protobuf.Config.Config
>();
public readonly onConfigPacket: SimpleEventDispatcher<Protobuf.Config.Config> =
new SimpleEventDispatcher<Protobuf.Config.Config>();
/**
* Fires when a new ModuleConfig message is received
*
* @event onModuleConfigPacket
*/
public readonly onModuleConfigPacket: SimpleEventDispatcher<
Protobuf.ModuleConfig.ModuleConfig
> = new SimpleEventDispatcher<
Protobuf.ModuleConfig.ModuleConfig
>();
public readonly onModuleConfigPacket: SimpleEventDispatcher<Protobuf.ModuleConfig.ModuleConfig> =
new SimpleEventDispatcher<Protobuf.ModuleConfig.ModuleConfig>();
/**
* Fires when a new MeshPacket message containing a ATAK packet has been
@@ -102,9 +78,7 @@ export class EventSystem {
*/
public readonly onAtakPacket: SimpleEventDispatcher<
PacketMetadata<Uint8Array>
> = new SimpleEventDispatcher<
PacketMetadata<Uint8Array>
>();
> = new SimpleEventDispatcher<PacketMetadata<Uint8Array>>();
/**
* Fires when a new MeshPacket message containing a Text packet has been
@@ -114,9 +88,7 @@ export class EventSystem {
*/
public readonly onMessagePacket: SimpleEventDispatcher<
PacketMetadata<string>
> = new SimpleEventDispatcher<
PacketMetadata<string>
>();
> = new SimpleEventDispatcher<PacketMetadata<string>>();
/**
* Fires when a new MeshPacket message containing a Remote Hardware packet has
@@ -138,9 +110,7 @@ export class EventSystem {
*/
public readonly onPositionPacket: SimpleEventDispatcher<
PacketMetadata<Protobuf.Mesh.Position>
> = new SimpleEventDispatcher<
PacketMetadata<Protobuf.Mesh.Position>
>();
> = new SimpleEventDispatcher<PacketMetadata<Protobuf.Mesh.Position>>();
/**
* Fires when a new MeshPacket message containing a User packet has been
@@ -150,9 +120,7 @@ export class EventSystem {
*/
public readonly onUserPacket: SimpleEventDispatcher<
PacketMetadata<Protobuf.Mesh.User>
> = new SimpleEventDispatcher<
PacketMetadata<Protobuf.Mesh.User>
>();
> = new SimpleEventDispatcher<PacketMetadata<Protobuf.Mesh.User>>();
/**
* Fires when a new MeshPacket message containing a Routing packet has been
@@ -162,9 +130,7 @@ export class EventSystem {
*/
public readonly onRoutingPacket: SimpleEventDispatcher<
PacketMetadata<Protobuf.Mesh.Routing>
> = new SimpleEventDispatcher<
PacketMetadata<Protobuf.Mesh.Routing>
>();
> = new SimpleEventDispatcher<PacketMetadata<Protobuf.Mesh.Routing>>();
/**
* Fires when the device receives a Metadata packet
@@ -173,9 +139,7 @@ export class EventSystem {
*/
public readonly onDeviceMetadataPacket: SimpleEventDispatcher<
PacketMetadata<Protobuf.Mesh.DeviceMetadata>
> = new SimpleEventDispatcher<
PacketMetadata<Protobuf.Mesh.DeviceMetadata>
>();
> = new SimpleEventDispatcher<PacketMetadata<Protobuf.Mesh.DeviceMetadata>>();
/**
* Fires when the device receives a Canned Message Module message packet
@@ -184,9 +148,7 @@ export class EventSystem {
*/
public readonly onCannedMessageModulePacket: SimpleEventDispatcher<
PacketMetadata<string>
> = new SimpleEventDispatcher<
PacketMetadata<string>
>();
> = new SimpleEventDispatcher<PacketMetadata<string>>();
/**
* Fires when a new MeshPacket message containing a Waypoint packet has been
@@ -196,9 +158,7 @@ export class EventSystem {
*/
public readonly onWaypointPacket: SimpleEventDispatcher<
PacketMetadata<Protobuf.Mesh.Waypoint>
> = new SimpleEventDispatcher<
PacketMetadata<Protobuf.Mesh.Waypoint>
>();
> = new SimpleEventDispatcher<PacketMetadata<Protobuf.Mesh.Waypoint>>();
/**
* Fires when a new MeshPacket message containing an Audio packet has been
@@ -208,9 +168,7 @@ export class EventSystem {
*/
public readonly onAudioPacket: SimpleEventDispatcher<
PacketMetadata<Uint8Array>
> = new SimpleEventDispatcher<
PacketMetadata<Uint8Array>
>();
> = new SimpleEventDispatcher<PacketMetadata<Uint8Array>>();
/**
* Fires when a new MeshPacket message containing a Detection Sensor packet has been
@@ -220,9 +178,7 @@ export class EventSystem {
*/
public readonly onDetectionSensorPacket: SimpleEventDispatcher<
PacketMetadata<Uint8Array>
> = new SimpleEventDispatcher<
PacketMetadata<Uint8Array>
>();
> = new SimpleEventDispatcher<PacketMetadata<Uint8Array>>();
/**
* Fires when a new MeshPacket message containing a Ping packet has been
@@ -232,9 +188,7 @@ export class EventSystem {
*/
public readonly onPingPacket: SimpleEventDispatcher<
PacketMetadata<Uint8Array>
> = new SimpleEventDispatcher<
PacketMetadata<Uint8Array>
>();
> = new SimpleEventDispatcher<PacketMetadata<Uint8Array>>();
/**
* Fires when a new MeshPacket message containing a IP Tunnel packet has been
@@ -244,9 +198,7 @@ export class EventSystem {
*/
public readonly onIpTunnelPacket: SimpleEventDispatcher<
PacketMetadata<Uint8Array>
> = new SimpleEventDispatcher<
PacketMetadata<Uint8Array>
>();
> = new SimpleEventDispatcher<PacketMetadata<Uint8Array>>();
/**
* Fires when a new MeshPacket message containing a Paxcounter packet has been
@@ -256,9 +208,7 @@ export class EventSystem {
*/
public readonly onPaxcounterPacket: SimpleEventDispatcher<
PacketMetadata<Protobuf.PaxCount.Paxcount>
> = new SimpleEventDispatcher<
PacketMetadata<Protobuf.PaxCount.Paxcount>
>();
> = new SimpleEventDispatcher<PacketMetadata<Protobuf.PaxCount.Paxcount>>();
/**
* Fires when a new MeshPacket message containing a Serial packet has been
@@ -268,9 +218,7 @@ export class EventSystem {
*/
public readonly onSerialPacket: SimpleEventDispatcher<
PacketMetadata<Uint8Array>
> = new SimpleEventDispatcher<
PacketMetadata<Uint8Array>
>();
> = new SimpleEventDispatcher<PacketMetadata<Uint8Array>>();
/**
* Fires when a new MeshPacket message containing a Store and Forward packet
@@ -280,9 +228,7 @@ export class EventSystem {
*/
public readonly onStoreForwardPacket: SimpleEventDispatcher<
PacketMetadata<Uint8Array>
> = new SimpleEventDispatcher<
PacketMetadata<Uint8Array>
>();
> = new SimpleEventDispatcher<PacketMetadata<Uint8Array>>();
/**
* Fires when a new MeshPacket message containing a Store and Forward packet
@@ -292,9 +238,7 @@ export class EventSystem {
*/
public readonly onRangeTestPacket: SimpleEventDispatcher<
PacketMetadata<Uint8Array>
> = new SimpleEventDispatcher<
PacketMetadata<Uint8Array>
>();
> = new SimpleEventDispatcher<PacketMetadata<Uint8Array>>();
/**
* Fires when a new MeshPacket message containing a Telemetry packet has been
@@ -304,9 +248,7 @@ export class EventSystem {
*/
public readonly onTelemetryPacket: SimpleEventDispatcher<
PacketMetadata<Protobuf.Telemetry.Telemetry>
> = new SimpleEventDispatcher<
PacketMetadata<Protobuf.Telemetry.Telemetry>
>();
> = new SimpleEventDispatcher<PacketMetadata<Protobuf.Telemetry.Telemetry>>();
/**
* Fires when a new MeshPacket message containing a ZPS packet has been
@@ -316,9 +258,7 @@ export class EventSystem {
*/
public readonly onZpsPacket: SimpleEventDispatcher<
PacketMetadata<Uint8Array>
> = new SimpleEventDispatcher<
PacketMetadata<Uint8Array>
>();
> = new SimpleEventDispatcher<PacketMetadata<Uint8Array>>();
/**
* Fires when a new MeshPacket message containing a Simulator packet has been
@@ -328,9 +268,7 @@ export class EventSystem {
*/
public readonly onSimulatorPacket: SimpleEventDispatcher<
PacketMetadata<Uint8Array>
> = new SimpleEventDispatcher<
PacketMetadata<Uint8Array>
>();
> = new SimpleEventDispatcher<PacketMetadata<Uint8Array>>();
/**
* Fires when a new MeshPacket message containing a Trace Route packet has been
@@ -340,9 +278,7 @@ export class EventSystem {
*/
public readonly onTraceRoutePacket: SimpleEventDispatcher<
PacketMetadata<Protobuf.Mesh.RouteDiscovery>
> = new SimpleEventDispatcher<
PacketMetadata<Protobuf.Mesh.RouteDiscovery>
>();
> = new SimpleEventDispatcher<PacketMetadata<Protobuf.Mesh.RouteDiscovery>>();
/**
* Fires when a new MeshPacket message containing a Neighbor Info packet has been
@@ -352,9 +288,7 @@ export class EventSystem {
*/
public readonly onNeighborInfoPacket: SimpleEventDispatcher<
PacketMetadata<Protobuf.Mesh.NeighborInfo>
> = new SimpleEventDispatcher<
PacketMetadata<Protobuf.Mesh.NeighborInfo>
>();
> = new SimpleEventDispatcher<PacketMetadata<Protobuf.Mesh.NeighborInfo>>();
/**
* Fires when a new MeshPacket message containing an ATAK packet has been
@@ -364,9 +298,7 @@ export class EventSystem {
*/
public readonly onAtakPluginPacket: SimpleEventDispatcher<
PacketMetadata<Uint8Array>
> = new SimpleEventDispatcher<
PacketMetadata<Uint8Array>
>();
> = new SimpleEventDispatcher<PacketMetadata<Uint8Array>>();
/**
* Fires when a new MeshPacket message containing a Map Report packet has been
@@ -376,9 +308,7 @@ export class EventSystem {
*/
public readonly onMapReportPacket: SimpleEventDispatcher<
PacketMetadata<Uint8Array>
> = new SimpleEventDispatcher<
PacketMetadata<Uint8Array>
>();
> = new SimpleEventDispatcher<PacketMetadata<Uint8Array>>();
/**
* Fires when a new MeshPacket message containing a Private packet has been
@@ -388,9 +318,7 @@ export class EventSystem {
*/
public readonly onPrivatePacket: SimpleEventDispatcher<
PacketMetadata<Uint8Array>
> = new SimpleEventDispatcher<
PacketMetadata<Uint8Array>
>();
> = new SimpleEventDispatcher<PacketMetadata<Uint8Array>>();
/**
* Fires when a new MeshPacket message containing an ATAK Forwarder packet has been
@@ -400,20 +328,15 @@ export class EventSystem {
*/
public readonly onAtakForwarderPacket: SimpleEventDispatcher<
PacketMetadata<Uint8Array>
> = new SimpleEventDispatcher<
PacketMetadata<Uint8Array>
>();
> = new SimpleEventDispatcher<PacketMetadata<Uint8Array>>();
/**
* Fires when the devices connection or configuration status changes
*
* @event onDeviceStatus
*/
public readonly onDeviceStatus: SimpleEventDispatcher<
Types.DeviceStatusEnum
> = new SimpleEventDispatcher<
Types.DeviceStatusEnum
>();
public readonly onDeviceStatus: SimpleEventDispatcher<Types.DeviceStatusEnum> =
new SimpleEventDispatcher<Types.DeviceStatusEnum>();
/**
* Fires when a new FromRadio message containing a LogRecord packet has been
@@ -421,11 +344,8 @@ export class EventSystem {
*
* @event onLogRecord
*/
public readonly onLogRecord: SimpleEventDispatcher<
Protobuf.Mesh.LogRecord
> = new SimpleEventDispatcher<
Protobuf.Mesh.LogRecord
>();
public readonly onLogRecord: SimpleEventDispatcher<Protobuf.Mesh.LogRecord> =
new SimpleEventDispatcher<Protobuf.Mesh.LogRecord>();
/**
* Fires when the device receives a meshPacket, returns a timestamp
@@ -448,20 +368,14 @@ export class EventSystem {
*
* @event onpendingSettingsChange
*/
public readonly onPendingSettingsChange: SimpleEventDispatcher<
boolean
> = new SimpleEventDispatcher<
boolean
>();
public readonly onPendingSettingsChange: SimpleEventDispatcher<boolean> =
new SimpleEventDispatcher<boolean>();
/**
* Fires when a QueueStatus message is generated
*
* @event onQueueStatus
*/
public readonly onQueueStatus: SimpleEventDispatcher<
Protobuf.Mesh.QueueStatus
> = new SimpleEventDispatcher<
Protobuf.Mesh.QueueStatus
>();
public readonly onQueueStatus: SimpleEventDispatcher<Protobuf.Mesh.QueueStatus> =
new SimpleEventDispatcher<Protobuf.Mesh.QueueStatus>();
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { SimpleEventDispatcher } from "ste-simple-events";
import { fromBinary } from "@bufbuild/protobuf";
import * as Protobuf from "@meshtastic/protobufs";
import { SimpleEventDispatcher } from "ste-simple-events";
import type { PacketError, QueueItem } from "../types.ts";
export class Queue {
@@ -1,7 +1,7 @@
import { fromBinary } from "@bufbuild/protobuf";
import type { DeviceOutput } from "../../types.ts";
import { Constants, Protobuf, Types } from "../../../mod.ts";
import type { MeshDevice } from "../../../mod.ts";
import type { DeviceOutput } from "../../types.ts";
export const decodePacket = (device: MeshDevice) =>
new WritableStream<DeviceOutput>({
@@ -43,11 +43,9 @@ export const fromDeviceStream: () => TransformStream<Uint8Array, DeviceOutput> =
packet[malformedDetectorIndex + 1] === 0xc3
) {
console.warn(
`⚠️ Malformed packet found, discarding: ${
byteBuffer
`⚠️ Malformed packet found, discarding: ${byteBuffer
.subarray(0, malformedDetectorIndex - 1)
.toString()
}`,
.toString()}`,
);
byteBuffer = byteBuffer.subarray(malformedDetectorIndex);
@@ -7,9 +7,9 @@ export const toDeviceStream: TransformStream<Uint8Array, Uint8Array> =
const bufLen = chunk.length;
const header = new Uint8Array([
0x94,
0xC3,
(bufLen >> 8) & 0xFF,
bufLen & 0xFF,
0xc3,
(bufLen >> 8) & 0xff,
bufLen & 0xff,
]);
controller.enqueue(new Uint8Array([...header, ...chunk]));
},
+1 -1
View File
@@ -1,6 +1,6 @@
import crc16ccitt from "crc/calculators/crc16ccitt";
import { create, toBinary } from "@bufbuild/protobuf";
import * as Protobuf from "@meshtastic/protobufs";
import crc16ccitt from "crc/calculators/crc16ccitt";
//if counter > 35 then reset counter/clear/error/reject promise
type XmodemProps = (toRadio: Uint8Array, id?: number) => Promise<number>;
+7 -8
View File
@@ -1,7 +1,7 @@
import { Utils } from "@meshtastic/core";
import type { Types } from "@meshtastic/core";
import { Socket } from "node:net";
import { Readable, Writable } from "node:stream";
import { Utils } from "@meshtastic/core";
import type { Types } from "@meshtastic/core";
export class TransportNode implements Types.Transport {
private readonly _toDevice: WritableStream<Uint8Array>;
@@ -40,9 +40,9 @@ export class TransportNode implements Types.Transport {
console.error("Socket connection error:", err);
});
const fromDeviceSource = Readable.toWeb(connection) as ReadableStream<
Uint8Array
>;
const fromDeviceSource = Readable.toWeb(
connection,
) as ReadableStream<Uint8Array>;
this._fromDevice = fromDeviceSource.pipeThrough(Utils.fromDeviceStream());
// Stream for data going FROM the application TO the Meshtastic device.
@@ -52,9 +52,8 @@ export class TransportNode implements Types.Transport {
// The readable end of the transform is then piped to the Node.js socket.
// A similar assertion is needed here because `Writable.toWeb` also returns
// a generically typed stream (`WritableStream<any>`).
toDeviceTransform.readable.pipeTo(
Writable.toWeb(connection) as WritableStream<Uint8Array>,
)
toDeviceTransform.readable
.pipeTo(Writable.toWeb(connection) as WritableStream<Uint8Array>)
.catch((err) => {
console.error("Error piping data to socket:", err);
connection.destroy(err as Error);
+1 -3
View File
@@ -9,8 +9,6 @@
"@types/web-bluetooth": "npm:@types/web-bluetooth@^0.0.20"
},
"compilerOptions": {
"types": [
"@types/web-bluetooth"
]
"types": ["@types/web-bluetooth"]
}
}
@@ -3,9 +3,7 @@ import type { Types } from "@meshtastic/core";
export class TransportWebBluetooth implements Types.Transport {
private _toDevice: WritableStream<Uint8Array>;
private _fromDevice: ReadableStream<Types.DeviceOutput>;
private _fromDeviceController?: ReadableStreamDefaultController<
Types.DeviceOutput
>;
private _fromDeviceController?: ReadableStreamDefaultController<Types.DeviceOutput>;
private _isFirstWrite = true;
private toRadioCharacteristic: BluetoothRemoteGATTCharacteristic;
@@ -52,7 +50,8 @@ export class TransportWebBluetooth implements Types.Transport {
);
if (
!toRadioCharacteristic || !fromRadioCharacteristic ||
!toRadioCharacteristic ||
!fromRadioCharacteristic ||
!fromNumCharacteristic
) {
throw new Error("Failed to find required characteristics");
+1 -3
View File
@@ -9,8 +9,6 @@
"@types/w3c-web-serial": "npm:@types/w3c-web-serial@^1.0.7"
},
"compilerOptions": {
"types": [
"@types/w3c-web-serial"
]
"types": ["@types/w3c-web-serial"]
}
}
+14 -14
View File
@@ -98,7 +98,7 @@ improve the stability of future releases
## Development & Building
You'll need to download the package manager used with this repo. You can install
it by visiting [deno.com](https://deno.com/) and following the installation
it by visiting [bun.sh](https://bun.sh/) and following the installation
instructions listed on the home page.
### Development
@@ -106,13 +106,13 @@ instructions listed on the home page.
Install the dependencies.
```bash
deno i
bun install
```
Start the development server:
```bash
deno task dev
bun run dev
```
### Building and Packaging
@@ -120,33 +120,33 @@ deno task dev
Build the project:
```bash
deno task build
bun run build
```
GZip the output:
```bash
deno task package
bun run package
```
### Why Deno?
### Why Bun?
Meshtastic Web uses Deno as its development platform for several compelling
Meshtastic Web uses Bun as its development platform for several compelling
reasons:
- **Built-in Security**: Deno's security-first approach requires explicit
permissions for file, network, and environment access, reducing vulnerability
risks.
- **Fast Performance**: Bun is built from the ground up for speed, offering
significantly faster package installation and bundling compared to other
JavaScript runtimes.
- **TypeScript Support**: Native TypeScript support without additional
configuration, enhancing code quality and developer experience.
- **Modern JavaScript**: First-class support for ESM imports, top-level await,
and other modern JavaScript features.
- **Simplified Tooling**: Built-in formatter, linter, test runner, and bundler
eliminate the need for multiple third-party tools.
- **All-in-One Tooling**: Built-in package manager, bundler, test runner, and
transpiler eliminate the need for multiple third-party tools.
- **Node.js Compatibility**: Drop-in replacement for Node.js with better
performance and built-in tooling.
- **Reproducible Builds**: Lockfile ensures consistent builds across all
environments.
- **Web Standard APIs**: Uses browser-compatible APIs, making code more portable
between server and client environments.
### Contributing
+49
View File
@@ -0,0 +1,49 @@
{
"$schema": "https://biomejs.dev/schemas/2.0.5/schema.json",
"files": {
"includes": ["**", "!node_modules/**", "!**/*.css", "!dist/**", "!build/**", "!coverage/**", "!**/*.d.ts"],
"ignoreUnknown": false
},
"formatter": {
"enabled": true,
"formatWithErrors": false,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 80,
"attributePosition": "auto"
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"suspicious": {
"noExplicitAny": "error",
"noDebugger": "error"
},
"style": {
"useConst": "error",
"useBlockStatements": "error",
"useSingleVarDeclarator": "off"
},
"complexity": {
"noForEach": "off"
},
"correctness": {
"noUnusedVariables": "error",
"noUnusedImports": "error"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "double",
"semicolons": "always"
}
},
"json": {
"formatter": {
"indentStyle": "space",
"indentWidth": 2
}
}
}
-57
View File
@@ -1,57 +0,0 @@
{
"imports": {
"@app/": "./src/",
"@public/": "./public/",
"@pages/": "./src/pages/",
"@components/": "./src/components/",
"@core/": "./src/core/",
"@layouts/": "./src/layouts/",
"@std/path": "jsr:@std/path@^1.1.0"
},
"tasks": {
"build": "vite build",
"build:analyze": "BUNDLE_ANALYZE=true deno task build",
"lint": "deno lint src/",
"lint:fix": "deno lint --fix src/",
"format": "deno fmt src/",
"dev": "deno task dev:ui",
"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",
"generate:routes": "deno run -A npm:@tanstack/router-cli generate --outDir src/ routes --rootRoutePath /",
"package": "gzipper c -i html,js,css,png,ico,svg,json,webmanifest,txt dist dist/output && tar -cvf dist/build.tar -C ./dist/output/ ."
},
"include": ["src", "./vite-env.d.ts"],
"compilerOptions": {
"lib": [
"DOM",
"DOM.Iterable",
"ESNext",
"deno.window",
"deno.ns"
],
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"strictNullChecks": true,
"types": [
"vite/client",
"node",
"npm:@types/w3c-web-serial",
"npm:@types/web-bluetooth"
],
"strictPropertyInitialization": false
},
"exclude": [
"routeTree.gen.ts",
"node_modules/",
"dist",
"build",
"coverage",
"out",
".vscode-test"
]
}
+5
View File
@@ -18,6 +18,11 @@
href="https://cdn.jsdelivr.net/npm/@xz/fonts@1/serve/cascadia-code.min.css"
crossorigin="anonymous"
/>
<link
rel="stylesheet"
href="src/index.css"
crossorigin="anonymous"
/>
<meta name="theme-color" content="#67ea94" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="Meshtastic Web Client" />
+27 -17
View File
@@ -4,7 +4,6 @@
"type": "module",
"description": "Meshtastic web client",
"license": "GPL-3.0-only",
"repository": {
"type": "git",
"url": "git+https://github.com/meshtastic/web.git"
@@ -13,14 +12,25 @@
"url": "https://github.com/meshtastic/web/issues"
},
"simple-git-hooks": {
"pre-commit": "deno task lint:fix && deno task format"
"pre-commit": "bun run check:fix"
},
"homepage": "https://meshtastic.org",
"scripts": {
"build": "bunx --bun vite build",
"build:analyze": "BUNDLE_ANALYZE=true bun run build",
"check": "biome check src/",
"check:fix": "biome check --write src/",
"dev": "bunx --bun vite",
"test": "bunx --bun vitest",
"ts:check": "bun run tsc --noEmit",
"preview": "bunx --bun vite preview",
"generate:routes": "bun @tanstack/router-cli generate --outDir src/ routes --rootRoutePath /",
"package": "gzipper c -i html,js,css,png,ico,svg,json,webmanifest,txt dist dist/output && tar -cvf dist/build.tar -C ./dist/output/ ."
},
"dependencies": {
"@bufbuild/protobuf": "^2.6.0",
"@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",
@@ -41,19 +51,20 @@
"@radix-ui/react-toast": "^1.2.14",
"@radix-ui/react-toggle-group": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.7",
"@tanstack/react-router": "^1.125.4",
"@tanstack/react-router-devtools": "^1.125.4",
"@tanstack/router-cli": "^1.125.4",
"@tanstack/router-devtools": "^1.125.4",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/react-router": "^1.127.9",
"@tanstack/react-router-devtools": "^1.127.9",
"@tanstack/router-cli": "^1.127.8",
"@tanstack/router-devtools": "^1.127.9",
"@turf/turf": "^7.2.0",
"@types/node": "^24.0.10",
"@types/node": "^24.0.14",
"@types/web-bluetooth": "^0.0.21",
"base64-js": "^1.5.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"crypto-random-string": "^5.0.0",
"i18next": "^25.3.1",
"i18next": "^25.3.2",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"idb-keyval": "^6.2.2",
@@ -69,26 +80,25 @@
"react-map-gl": "8.0.4",
"react-qrcode-logo": "^3.0.0",
"rfc4648": "^1.5.4",
"zod": "^3.25.75",
"zod": "^4.0.5",
"zustand": "5.0.6"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.11",
"@tanstack/router-plugin": "^1.125.5",
"@biomejs/biome": "2.0.6",
"@tanstack/router-plugin": "^1.127.9",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/chrome": "^0.0.329",
"@types/chrome": "^0.1.0",
"@types/js-cookie": "^3.0.6",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@types/serviceworker": "^0.0.140",
"@types/js-cookie": "^3.0.6",
"@types/serviceworker": "^0.0.142",
"@types/w3c-web-serial": "^1.0.8",
"@vitejs/plugin-react": "^4.6.0",
"autoprefixer": "^10.4.21",
"gzipper": "^8.2.1",
"happy-dom": "^18.0.1",
"postcss": "^8.5.6",
"simple-git-hooks": "^2.13.0",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.11",
@@ -96,7 +106,7 @@
"tar": "^7.4.3",
"testing-library": "^0.0.2",
"typescript": "^5.8.3",
"vite": "^7.0",
"vite": "^7.0.4",
"vitest": "^3.2.4"
}
}
-5
View File
@@ -1,5 +0,0 @@
module.exports = {
plugins: {
"@tailwindcss/postcss": {},
},
};
+9 -12
View File
@@ -1,20 +1,19 @@
import { DeviceWrapper } from "@app/DeviceWrapper.tsx";
import { CommandPalette } from "@components/CommandPalette/index.tsx";
import { DialogManager } from "@components/Dialog/DialogManager.tsx";
import { NewDeviceDialog } from "@components/Dialog/NewDeviceDialog.tsx";
import { KeyBackupReminder } from "@components/KeyBackupReminder.tsx";
import { Toaster } from "@components/Toaster.tsx";
import { ErrorPage } from "@components/UI/ErrorPage.tsx";
import Footer from "@components/UI/Footer.tsx";
import { useTheme } from "@core/hooks/useTheme.ts";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDeviceStore } from "@core/stores/deviceStore.ts";
import { Dashboard } from "@pages/Dashboard/index.tsx";
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 { Dashboard } from "@pages/Dashboard/index.tsx";
import { Outlet } from "@tanstack/react-router";
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
import { ErrorBoundary } from "react-error-boundary";
import { MapProvider } from "react-map-gl/maplibre";
export function App() {
const { getDevice } = useDeviceStore();
@@ -34,7 +33,7 @@ export function App() {
setConnectDialogOpen(open);
}}
/>
<Toaster />
{/* <Toaster /> */}
<TanStackRouterDevtools position="bottom-right" />
<DeviceWrapper device={device}>
<div
@@ -43,8 +42,7 @@ export function App() {
>
<SidebarProvider>
<div className="h-full flex flex-col">
{device
? (
{device ? (
<div className="h-full flex w-full">
<DialogManager />
<KeyBackupReminder />
@@ -53,8 +51,7 @@ export function App() {
<Outlet />
</MapProvider>
</div>
)
: (
) : (
<>
<Dashboard />
<Footer />
+1 -1
View File
@@ -1,5 +1,5 @@
import { DeviceContext } from "@core/stores/deviceStore.ts";
import type { Device } from "@core/stores/deviceStore.ts";
import { DeviceContext } from "@core/stores/deviceStore.ts";
import type { ReactNode } from "react";
export interface DeviceWrapperProps {
@@ -1,7 +1,12 @@
import { vi } from "vitest";
vi.mock("@components/UI/Button.tsx", () => ({
Button: ({ children, name, disabled, onClick }: {
Button: ({
children,
name,
disabled,
onClick,
}: {
children: React.ReactNode;
variant: string;
name: string;
@@ -1,13 +1,15 @@
import { vi } from "vitest";
vi.mock("@components/UI/Checkbox.tsx", () => ({
Checkbox: (
{ id, checked, onChange }: {
Checkbox: ({
id,
checked,
onChange,
}: {
id: string;
checked: boolean;
onChange: () => void;
},
) => (
}) => (
<input
data-testid="checkbox"
type="checkbox"
@@ -1,10 +1,13 @@
import React from "react";
import type React from "react";
export const Dialog = ({ children, open }: {
export const Dialog = ({
children,
open,
}: {
children: React.ReactNode;
open: boolean;
onOpenChange?: (open: boolean) => void;
}) => open ? <div data-testid="dialog">{children}</div> : null;
}) => (open ? <div data-testid="dialog">{children}</div> : null);
export const DialogContent = ({
children,
@@ -12,19 +15,19 @@ export const DialogContent = ({
}: {
children: React.ReactNode;
className?: string;
}) => <div data-testid="dialog-content" className={className}>{children}</div>;
}) => (
<div data-testid="dialog-content" className={className}>
{children}
</div>
);
export const DialogHeader = ({
children,
}: {
children: React.ReactNode;
}) => <div data-testid="dialog-header">{children}</div>;
export const DialogHeader = ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-header">{children}</div>
);
export const DialogTitle = ({
children,
}: {
children: React.ReactNode;
}) => <div data-testid="dialog-title">{children}</div>;
export const DialogTitle = ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-title">{children}</div>
);
export const DialogDescription = ({
children,
@@ -33,7 +36,9 @@ export const DialogDescription = ({
children: React.ReactNode;
className?: string;
}) => (
<div data-testid="dialog-description" className={className}>{children}</div>
<div data-testid="dialog-description" className={className}>
{children}
</div>
);
export const DialogFooter = ({
@@ -42,4 +47,8 @@ export const DialogFooter = ({
}: {
children: React.ReactNode;
className?: string;
}) => <div data-testid="dialog-footer" className={className}>{children}</div>;
}) => (
<div data-testid="dialog-footer" className={className}>
{children}
</div>
);
@@ -1,13 +1,15 @@
import { vi } from "vitest";
vi.mock("@components/UI/Label.tsx", () => ({
Label: (
{ children, htmlFor, className }: {
Label: ({
children,
htmlFor,
className,
}: {
children: React.ReactNode;
htmlFor: string;
className?: string;
},
) => (
}) => (
<label data-testid="label" htmlFor={htmlFor} className={className}>
{children}
</label>
@@ -1,11 +1,17 @@
import { vi } from "vitest";
vi.mock("@components/UI/Typography/Link.tsx", () => ({
Link: (
{ children, href, className }: {
Link: ({
children,
href,
className,
}: {
children: React.ReactNode;
href: string;
className?: string;
},
) => <a data-testid="link" href={href} className={className}>{children}</a>,
}) => (
<a data-testid="link" href={href} className={className}>
{children}
</a>
),
}));
+3 -10
View File
@@ -1,12 +1,12 @@
import React from "react";
import {
BatteryFullIcon,
BatteryLowIcon,
BatteryMediumIcon,
PlugZapIcon,
} from "lucide-react";
import type React from "react";
import { useTranslation } from "react-i18next";
import { DeviceMetrics } from "./types.ts";
import type { DeviceMetrics } from "./types.ts";
type BatteryStatusKey = keyof typeof BATTERY_STATUS;
@@ -14,10 +14,6 @@ interface BatteryStatusProps {
deviceMetrics?: DeviceMetrics | null;
}
interface BatteryStatusProps {
deviceMetrics?: DeviceMetrics | null;
}
interface StatusConfig {
Icon: React.ElementType;
className: string;
@@ -89,10 +85,7 @@ const BatteryStatus: React.FC<BatteryStatusProps> = ({ deviceMetrics }) => {
} = statusConfigMap[statusKey];
return (
<div
className="flex items-center gap-1 mt-0.5 "
aria-label={t("batteryStatus.title")}
>
<div className="flex items-center gap-1 mt-0.5">
<BatteryIcon size={22} className={iconClassName} />
{statusText}
</div>
@@ -1,3 +1,4 @@
import { Avatar } from "@components/UI/Avatar.tsx";
import {
CommandDialog,
CommandEmpty,
@@ -6,8 +7,11 @@ import {
CommandItem,
CommandList,
} from "@components/UI/Command.tsx";
import { usePinnedItems } from "@core/hooks/usePinnedItems.ts";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDevice, useDeviceStore } from "@core/stores/deviceStore.ts";
import { cn } from "@core/utils/cn.ts";
import { useNavigate } from "@tanstack/react-router";
import { useCommandState } from "cmdk";
import {
ArrowLeftRightIcon,
@@ -32,11 +36,7 @@ import {
UsersIcon,
} 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;
@@ -125,12 +125,15 @@ export const CommandPalette = () => {
label: t("manage.command.switchNode"),
icon: ArrowLeftRightIcon,
subItems: getDevices().map((device) => ({
label: getNode(device.hardware.myNodeNum)?.user?.longName ??
label:
getNode(device.hardware.myNodeNum)?.user?.longName ??
t("unknown.shortName"),
icon: (
<Avatar
text={getNode(device.hardware.myNodeNum)?.user?.shortName ??
t("unknown.shortName")}
text={
getNode(device.hardware.myNodeNum)?.user?.shortName ??
t("unknown.shortName")
}
/>
),
action() {
@@ -286,9 +289,6 @@ export const CommandPalette = () => {
className={cn(
"transition-all duration-300 scale-100 cursor-pointer p-2 focus:*:data-label:opacity-100",
)}
aria-description={pinnedItems.includes(group.label)
? t("unpinGroup.label")
: t("pinGroup.label")}
>
<span
data-label
@@ -345,7 +345,9 @@ const SubItem = ({
action: () => void;
}) => {
const search = useCommandState((state) => state.search);
if (!search) return null;
if (!search) {
return null;
}
return (
<CommandItem onSelect={action}>
+14 -19
View File
@@ -8,15 +8,16 @@ import {
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 type React from "react";
import { Fragment } from "react";
import { useTranslation } from "react-i18next";
import ThemeSwitcher from "./ThemeSwitcher.tsx";
import BatteryStatus from "./BatteryStatus.tsx";
import LanguageSwitcher from "./LanguageSwitcher.tsx";
import ThemeSwitcher from "./ThemeSwitcher.tsx";
import type { DeviceMetrics } from "./types.ts";
import { Avatar } from "./UI/Avatar.tsx";
import { Button } from "./UI/Button.tsx";
import { Subtle } from "./UI/Typography/Subtle.tsx";
interface DeviceInfoPanelProps {
isCollapsed: boolean;
@@ -70,7 +71,8 @@ export const DeviceInfoPanel = ({
id: "voltage",
label: t("batteryVoltage.title"),
icon: ZapIcon,
value: voltage !== undefined
value:
voltage !== undefined
? `${voltage?.toPrecision(3)} V`
: t("unknown.notAvailable", "N/A"),
},
@@ -135,8 +137,7 @@ export const DeviceInfoPanel = ({
</div>
{!isCollapsed && (
<div className="my-2 h-px bg-gray-200 dark:bg-gray-700 flex-shrink-0">
</div>
<div className="my-2 h-px bg-gray-200 dark:bg-gray-700 flex-shrink-0" />
)}
<div
@@ -151,10 +152,7 @@ export const DeviceInfoPanel = ({
{deviceInfoItems.map((item) => {
const IconComponent = item.icon;
return (
<div
key={item.id}
className="flex items-center gap-2.5 text-sm"
>
<div key={item.id} className="flex items-center gap-2.5 text-sm">
{IconComponent && (
<IconComponent
size={16}
@@ -173,8 +171,7 @@ export const DeviceInfoPanel = ({
</div>
{!isCollapsed && (
<div className="my-2 h-px bg-gray-200 dark:bg-gray-700 flex-shrink-0">
</div>
<div className="my-2 h-px bg-gray-200 dark:bg-gray-700 flex-shrink-0" />
)}
<div
@@ -190,9 +187,7 @@ export const DeviceInfoPanel = ({
const Icon = buttonItem.icon;
if (buttonItem.render) {
return (
<Fragment key={buttonItem.id}>
{buttonItem.render()}
</Fragment>
<Fragment key={buttonItem.id}>{buttonItem.render()}</Fragment>
);
}
return (
@@ -1,8 +1,8 @@
import { DeleteMessagesDialog } from "@components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx";
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(() => ({
@@ -26,9 +26,7 @@ describe("DeleteMessagesDialog", () => {
});
it("calls onOpenChange with false when the close button (X) is clicked", () => {
render(
<DeleteMessagesDialog open onOpenChange={mockOnOpenChange} />,
);
render(<DeleteMessagesDialog open onOpenChange={mockOnOpenChange} />);
const closeButton = screen.queryByTestId("dialog-close-button");
if (!closeButton) {
throw new Error(
@@ -41,15 +39,15 @@ describe("DeleteMessagesDialog", () => {
});
it("renders the dialog when open is true", () => {
render(
<DeleteMessagesDialog open onOpenChange={mockOnOpenChange} />,
);
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.getByText(/This action will clear all message history./),
).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Dismiss" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Clear Messages" }))
.toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Clear Messages" }),
).toBeInTheDocument();
});
it("does not render the dialog when open is false", () => {
@@ -60,18 +58,14 @@ describe("DeleteMessagesDialog", () => {
});
it("calls onOpenChange with false when the dismiss button is clicked", () => {
render(
<DeleteMessagesDialog open onOpenChange={mockOnOpenChange} />,
);
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} />,
);
render(<DeleteMessagesDialog open onOpenChange={mockOnOpenChange} />);
fireEvent.click(screen.getByRole("button", { name: "Clear Messages" }));
expect(mockClearAllMessages).toHaveBeenCalledTimes(1);
expect(mockOnOpenChange).toHaveBeenCalledTimes(1); // Add count check
@@ -9,8 +9,8 @@ import {
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { AlertTriangleIcon } from "lucide-react";
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
import { useTranslation } from "react-i18next";
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
export interface DeleteMessagesDialogProps {
open: boolean;
@@ -41,11 +41,7 @@ export const DeleteMessagesDialog = ({
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-4">
<Button
variant="outline"
onClick={handleCloseDialog}
name="dismiss"
>
<Button variant="outline" onClick={handleCloseDialog} name="dismiss">
{t("button.dismiss")}
</Button>
<Button
@@ -1,5 +1,5 @@
import { useDevice } from "@core/stores/deviceStore.ts";
import { create } from "@bufbuild/protobuf";
import { GenericInput } from "@components/Form/FormInput.tsx";
import { Button } from "@components/UI/Button.tsx";
import {
Dialog,
@@ -10,13 +10,13 @@ import {
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { zodResolver } from "@hookform/resolvers/zod";
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";
import { Label } from "../UI/Label.tsx";
export interface User {
longName: string;
@@ -76,15 +76,11 @@ export const DeviceNameDialog = ({
<DialogClose />
<DialogHeader>
<DialogTitle>{t("deviceName.title")}</DialogTitle>
<DialogDescription>
{t("deviceName.description")}
</DialogDescription>
<DialogDescription>{t("deviceName.description")}</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit} className="flex flex-col gap-4">
<div>
<Label htmlFor="longName">
{t("deviceName.longName")}
</Label>
<Label htmlFor="longName">{t("deviceName.longName")}</Label>
<GenericInput
control={control}
field={{
@@ -104,9 +100,7 @@ export const DeviceNameDialog = ({
/>
</div>
<div>
<Label htmlFor="shortName">
{t("deviceName.shortName")}
</Label>
<Label htmlFor="shortName">{t("deviceName.shortName")}</Label>
<GenericInput
control={control}
field={{
@@ -134,10 +128,7 @@ export const DeviceNameDialog = ({
>
{t("button.reset")}
</Button>
<Button
type="submit"
name="save"
>
<Button type="submit" name="save">
{t("button.save")}
</Button>
</DialogFooter>
@@ -1,16 +1,16 @@
import { useDevice } from "@core/stores/deviceStore.ts";
import { RemoveNodeDialog } from "@components/Dialog/RemoveNodeDialog.tsx";
import { DeleteMessagesDialog } from "@components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx";
import { DeviceNameDialog } from "@components/Dialog/DeviceNameDialog.tsx";
import { ImportDialog } from "@components/Dialog/ImportDialog.tsx";
import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx";
import { PkiBackupDialog } from "@components/Dialog/PKIBackupDialog.tsx";
import { QRDialog } from "@components/Dialog/QRDialog.tsx";
import { RebootDialog } from "@components/Dialog/RebootDialog.tsx";
import { ShutdownDialog } from "@components/Dialog/ShutdownDialog.tsx";
import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx";
import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx";
import { RefreshKeysDialog } from "@components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx";
import { RebootOTADialog } from "@components/Dialog/RebootOTADialog.tsx";
import { DeleteMessagesDialog } from "@components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx";
import { RefreshKeysDialog } from "@components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx";
import { RemoveNodeDialog } from "@components/Dialog/RemoveNodeDialog.tsx";
import { ShutdownDialog } from "@components/Dialog/ShutdownDialog.tsx";
import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
export const DialogManager = () => {
const { channels, config, dialog, setDialogOpen } = useDevice();
@@ -1,6 +1,5 @@
import { create, fromBinary } from "@bufbuild/protobuf";
import { Button } from "@components/UI/Button.tsx";
import { Checkbox } from "../UI/Checkbox/index.tsx";
import {
Dialog,
DialogClose,
@@ -18,6 +17,7 @@ import { Protobuf } from "@meshtastic/core";
import { toByteArray } from "base64-js";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Checkbox } from "../UI/Checkbox/index.tsx";
export interface ImportDialogProps {
open: boolean;
@@ -25,10 +25,7 @@ export interface ImportDialogProps {
loraConfig?: Protobuf.Config.Config_LoRaConfig;
}
export const ImportDialog = ({
open,
onOpenChange,
}: ImportDialogProps) => {
export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => {
const { t } = useTranslation("dialog");
const [importDialogInput, setImportDialogInput] = useState<string>("");
const [channelSet, setChannelSet] = useState<Protobuf.AppOnly.ChannelSet>();
@@ -69,7 +66,7 @@ export const ImportDialog = ({
setValidUrl(false);
setChannelSet(undefined);
}
}, [importDialogInput]);
}, [importDialogInput, t]);
const apply = () => {
channelSet?.settings.map(
@@ -77,7 +74,8 @@ export const ImportDialog = ({
connection?.setChannel(
create(Protobuf.Channel.ChannelSchema, {
index,
role: index === 0
role:
index === 0
? Protobuf.Channel.Channel_Role.PRIMARY
: Protobuf.Channel.Channel_Role.SECONDARY,
settings: ch,
@@ -104,9 +102,7 @@ export const ImportDialog = ({
<DialogClose />
<DialogHeader>
<DialogTitle>{t("import.title")}</DialogTitle>
<DialogDescription>
{t("import.description")}
</DialogDescription>
<DialogDescription>{t("import.description")}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
<Label>{t("import.channelSetUrl")}</Label>
@@ -127,25 +123,21 @@ export const ImportDialog = ({
checked={channelSet?.loraConfig?.usePreset ?? true}
/>
</div>
{
/* <Select
{/* <Select
label="Modem Preset"
disabled
value={channelSet?.loraConfig?.modemPreset}
>
{renderOptions(Protobuf.Config_LoRaConfig_ModemPreset)}
</Select> */
}
</Select> */}
</div>
{
/* <Select
{/* <Select
label="Region"
disabled
value={channelSet?.loraConfig?.region}
>
{renderOptions(Protobuf.Config_LoRaConfig_RegionCode)}
</Select> */
}
</Select> */}
<span className="text-md block font-medium text-text-primary">
{t("import.channels")}
@@ -1,4 +1,7 @@
import { useDevice } from "@core/stores/deviceStore.ts";
import type { Protobuf, Types } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { useTranslation } from "react-i18next";
import {
Dialog,
DialogClose,
@@ -7,9 +10,6 @@ import {
DialogHeader,
DialogTitle,
} 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.Position> | undefined;
@@ -26,16 +26,19 @@ export const LocationResponseDialog = ({
const { getNode } = useDevice();
const from = getNode(location?.from ?? 0);
const longName = from?.user?.longName ??
const longName =
from?.user?.longName ??
(from ? `!${numberToHexUnpadded(from?.num)}` : t("unknown.shortName"));
const shortName = from?.user?.shortName ??
const shortName =
from?.user?.shortName ??
(from
? `${numberToHexUnpadded(from?.num).substring(0, 4)}`
: t("unknown.shortName"));
const position = location?.data;
const hasCoordinates = position &&
const hasCoordinates =
position &&
typeof position.latitudeI === "number" &&
typeof position.longitudeI === "number" &&
typeof position.altitude === "number";
@@ -53,8 +56,7 @@ export const LocationResponseDialog = ({
</DialogTitle>
</DialogHeader>
<DialogDescription>
{hasCoordinates
? (
{hasCoordinates ? (
<div className="ml-5 flex">
<span className="ml-4 border-l-2 border-l-backgroundPrimary pl-2 text-textPrimary">
<p>
@@ -80,8 +82,7 @@ export const LocationResponseDialog = ({
</p>
</span>
</div>
)
: (
) : (
// Optional: Show a message if coordinates are not available
<p className="text-textPrimary">
{t("locationResponse.noCoordinates")}
@@ -1,4 +1,5 @@
import { Button } from "@components/UI/Button.tsx";
import { Checkbox } from "@components/UI/Checkbox/index.tsx";
import {
Dialog,
DialogClose,
@@ -8,9 +9,8 @@ import {
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";
import { Trans, useTranslation } from "react-i18next";
export interface ManagedModeDialogProps {
open: boolean;
@@ -36,7 +36,7 @@ export const ManagedModeDialog = ({
<Trans
i18nKey="managedMode.description"
components={{
"bold": <p className="font-bold inline" />,
bold: <p className="font-bold inline" />,
}}
/>
</DialogDescription>
@@ -1,7 +1,3 @@
import {
type BrowserFeature,
useBrowserFeatureDetection,
} 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,6 +14,10 @@ import {
TabsList,
TabsTrigger,
} from "@components/UI/Tabs.tsx";
import {
type BrowserFeature,
useBrowserFeatureDetection,
} from "@core/hooks/useBrowserFeatureDetection.ts";
import { AlertCircle } from "lucide-react";
import { Trans, useTranslation } from "react-i18next";
import { Link } from "../UI/Typography/Link.tsx";
@@ -45,24 +45,23 @@ interface FeatureErrorProps {
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",
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",
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",
href: "https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts",
i18nKey: "newDeviceDialog.validation.requiresSecureContext",
},
};
const ErrorMessage = ({ missingFeatures, tabId }: FeatureErrorProps) => {
if (missingFeatures.length === 0) return null;
if (missingFeatures.length === 0) {
return null;
}
const browserFeatures = missingFeatures.filter(
(feature) => feature !== "Secure Context",
@@ -70,9 +69,9 @@ const ErrorMessage = ({ missingFeatures, tabId }: FeatureErrorProps) => {
const needsSecureContext = missingFeatures.includes("Secure Context");
const needsFeature =
(tabId === "BLE" && browserFeatures.includes("Web Bluetooth"))
tabId === "BLE" && browserFeatures.includes("Web Bluetooth")
? "Web Bluetooth"
: (tabId === "Serial" && browserFeatures.includes("Web Serial"))
: tabId === "Serial" && browserFeatures.includes("Web Serial")
? "Web Serial"
: undefined;
@@ -97,9 +96,11 @@ const ErrorMessage = ({ missingFeatures, tabId }: FeatureErrorProps) => {
{needsFeature && needsSecureContext && " "}
{needsSecureContext && (
<Trans
i18nKey={browserFeatures.length > 0
i18nKey={
browserFeatures.length > 0
? "newDeviceDialog.validation.additionallyRequiresSecureContext"
: "newDeviceDialog.validation.requiresSecureContext"}
: "newDeviceDialog.validation.requiresSecureContext"
}
components={{
"0": (
<Link
@@ -117,10 +118,7 @@ const ErrorMessage = ({ missingFeatures, tabId }: FeatureErrorProps) => {
);
};
export const NewDeviceDialog = ({
open,
onOpenChange,
}: NewDeviceProps) => {
export const NewDeviceDialog = ({ open, onOpenChange }: NewDeviceProps) => {
const { t } = useTranslation("dialog");
const { unsupported } = useBrowserFeatureDetection();
@@ -135,14 +133,16 @@ export const NewDeviceDialog = ({
id: "BLE",
label: t("newDeviceDialog.tabBluetooth"),
element: BLE,
isDisabled: unsupported.includes("Web Bluetooth") ||
isDisabled:
unsupported.includes("Web Bluetooth") ||
unsupported.includes("Secure Context"),
},
{
id: "Serial",
label: t("newDeviceDialog.tabSerial"),
element: Serial,
isDisabled: unsupported.includes("Web Serial") ||
isDisabled:
unsupported.includes("Web Serial") ||
unsupported.includes("Secure Context"),
},
];
@@ -165,18 +165,10 @@ export const NewDeviceDialog = ({
{tabs.map((tab) => (
<TabsContent key={tab.id} value={tab.id}>
<fieldset disabled={tab.isDisabled}>
{(tab.id !== "HTTP" &&
tab.isDisabled)
? (
<ErrorMessage
missingFeatures={unsupported}
tabId={tab.id}
/>
)
: (
<tab.element
closeDialog={() => onOpenChange(false)}
/>
{tab.id !== "HTTP" && tab.isDisabled ? (
<ErrorMessage missingFeatures={unsupported} tabId={tab.id} />
) : (
<tab.element closeDialog={() => onOpenChange(false)} />
)}
</fieldset>
</TabsContent>
@@ -1,22 +1,13 @@
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,
AccordionItem,
AccordionTrigger,
} from "@components/UI/Accordion.tsx";
import { Button } from "@components/UI/Button.tsx";
import {
Dialog,
DialogClose,
@@ -25,7 +16,23 @@ import {
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { Button } from "@components/UI/Button.tsx";
import { Separator } from "@components/UI/Seperator.tsx";
import {
Tooltip,
TooltipArrow,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@components/UI/Tooltip.tsx";
import { useFavoriteNode } from "@core/hooks/useFavoriteNode.ts";
import { useIgnoreNode } from "@core/hooks/useIgnoreNode.ts";
import { toast } from "@core/hooks/useToast.ts";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDevice } from "@core/stores/deviceStore.ts";
import { cn } from "@core/utils/cn.ts";
import { Protobuf } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { useNavigate } from "@tanstack/react-router";
import {
BellIcon,
BellOffIcon,
@@ -35,16 +42,8 @@ import {
TrashIcon,
WaypointsIcon,
} from "lucide-react";
import {
Tooltip,
TooltipArrow,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@components/UI/Tooltip.tsx";
import { Separator } from "@components/UI/Seperator.tsx";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "@tanstack/react-router";
export interface NodeDetailsDialogProps {
open: boolean;
@@ -72,21 +71,29 @@ export const NodeDetailsDialog = ({
);
useEffect(() => {
if (!node) return;
if (!node) {
return;
}
setIsFavoriteState(node?.isFavorite);
setIsIgnoredState(node?.isIgnored);
}, [node]);
if (!node) return;
if (!node) {
return;
}
function handleDirectMessage() {
if (!node) return;
if (!node) {
return;
}
navigate({ to: `/messages/direct/${node.num}` });
setDialogOpen("nodeDetails", false);
}
function handleRequestPosition() {
if (!node) return;
if (!node) {
return;
}
toast({
title: t("toast.requestingPosition.title", { ns: "ui" }),
@@ -94,13 +101,15 @@ export const NodeDetailsDialog = ({
connection?.requestPosition(node.num).then(() =>
toast({
title: t("toast.positionRequestSent.title", { ns: "ui" }),
})
}),
);
onOpenChange(false);
}
function handleTraceroute() {
if (!node) return;
if (!node) {
return;
}
toast({
title: t("toast.sendingTraceroute.title", { ns: "ui" }),
@@ -108,13 +117,15 @@ export const NodeDetailsDialog = ({
connection?.traceRoute(node.num).then(() =>
toast({
title: t("toast.tracerouteSent.title", { ns: "ui" }),
})
}),
);
onOpenChange(false);
}
function handleNodeRemove() {
if (!node) return;
if (!node) {
return;
}
setNodeNumToBeRemoved(node?.num);
setDialogOpen("nodeRemoval", true);
@@ -122,14 +133,18 @@ export const NodeDetailsDialog = ({
}
function handleToggleFavorite() {
if (!node) return;
if (!node) {
return;
}
updateFavorite({ nodeNum: node.num, isFavorite: !isFavoriteState });
setIsFavoriteState(!isFavoriteState);
}
function handleToggleIgnored() {
if (!node) return;
if (!node) {
return;
}
updateIgnored({ nodeNum: node.num, isIgnored: !isIgnoredState });
setIsIgnoredState(!isIgnoredState);
@@ -202,7 +217,7 @@ export const NodeDetailsDialog = ({
)}
/>
</Button>
<div className="flex flex-1 justify-start"></div>
<div className="flex flex-1 justify-start" />
<TooltipProvider delayDuration={300}>
<Tooltip>
@@ -255,7 +270,10 @@ export const NodeDetailsDialog = ({
<p className="text-lg font-semibold">
{t("nodeDetails.details")}
</p>
<p>{t("nodeDetails.nodeNumber")}{node.num}</p>
<p>
{t("nodeDetails.nodeNumber")}
{node.num}
</p>
<p>
{t("nodeDetails.nodeHexPrefix")}
{numberToHexUnpadded(node.num)}
@@ -268,21 +286,25 @@ export const NodeDetailsDialog = ({
</p>
<p>
{t("nodeDetails.lastHeard")}
{node.lastHeard === 0
? t("nodesTable.lastHeardStatus.never", { ns: "nodes" })
: <TimeAgo timestamp={node.lastHeard * 1000} />}
{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, " ")}
{(
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]}
deviceType={
Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]
}
/>
</div>
</div>
@@ -293,11 +315,9 @@ export const NodeDetailsDialog = ({
{t("nodeDetails.position")}
</p>
{node.position
? (
{node.position ? (
<>
{node.position.latitudeI &&
node.position.longitudeI && (
{node.position.latitudeI && node.position.longitudeI && (
<p>
{t("locationResponse.coordinates")}
<a
@@ -321,8 +341,9 @@ export const NodeDetailsDialog = ({
</p>
)}
</>
)
: <p>{t("unknown.shortName")}</p>}
) : (
<p>{t("unknown.shortName")}</p>
)}
<Button
onClick={handleRequestPosition}
name="requestPosition"
@@ -338,14 +359,13 @@ export const NodeDetailsDialog = ({
<p className="text-lg font-semibold text-slate-900 dark:text-slate-100">
{t("nodeDetails.deviceMetrics")}
</p>
{deviceMetricsMap.map(
(metric) =>
metric.value !== undefined && (
{deviceMetricsMap
.filter((metric) => metric.value !== undefined)
.map((metric) => (
<p key={metric.key}>
{metric.label}: {metric.format(metric.value)}
{metric.label}: {metric.format(metric?.value ?? 0)}
</p>
),
)}
))}
{node.deviceMetrics.uptimeSeconds && (
<p>
{t("nodeDetails.uptime")}
@@ -1,5 +1,3 @@
import { useDevice } from "../../core/stores/deviceStore.ts";
import { Button } from "../UI/Button.tsx";
import {
Dialog,
DialogClose,
@@ -13,6 +11,8 @@ import { fromByteArray } from "base64-js";
import { DownloadIcon, PrinterIcon } from "lucide-react";
import React from "react";
import { useTranslation } from "react-i18next";
import { useDevice } from "../../core/stores/deviceStore.ts";
import { Button } from "../UI/Button.tsx";
export interface PkiBackupDialogProps {
open: boolean;
@@ -30,7 +30,9 @@ export const PkiBackupDialog = ({
const decodeKeyData = React.useCallback(
(key: Uint8Array<ArrayBufferLike>) => {
if (!key) return "";
if (!key) {
return "";
}
return fromByteArray(key ?? new Uint8Array(0));
},
[],
@@ -41,20 +43,20 @@ export const PkiBackupDialog = ({
}, [setDialogOpen]);
const renderPrintWindow = React.useCallback(() => {
if (!privateKey || !publicKey) return;
if (!privateKey || !publicKey) {
return;
}
const printWindow = globalThis.open("", "_blank");
if (printWindow) {
printWindow.document.write(`
<html>
<head>
<title>${
t("pkiBackup.header", {
<title>${t("pkiBackup.header", {
interpolation: { escapeValue: false },
shortName: getMyNode()?.user?.shortName ?? t("unknown.shortName"),
longName: getMyNode()?.user?.longName ?? t("unknown.longName"),
})
}</title>
})}</title>
<style>
body { font-family: Arial, sans-serif; padding: 20px; }
h1 { font-size: 18px; }
@@ -62,13 +64,11 @@ export const PkiBackupDialog = ({
</style>
</head>
<body>
<h1>${
t("pkiBackup.header", {
<h1>${t("pkiBackup.header", {
interpolation: { escapeValue: false },
shortName: getMyNode()?.user?.shortName ?? t("unknown.shortName"),
longName: getMyNode()?.user?.longName ?? t("unknown.longName"),
})
}</h1>
})}</h1>
<h3>${t("pkiBackup.secureBackup")}</h3>
<h3>${t("pkiBackup.publicKey")}</h3>
<p>${decodeKeyData(publicKey)}</p>
@@ -82,10 +82,12 @@ export const PkiBackupDialog = ({
printWindow.print();
closeDialog();
}
}, [decodeKeyData, privateKey, publicKey, closeDialog, t]);
}, [decodeKeyData, privateKey, publicKey, closeDialog, t, getMyNode]);
const createDownloadKeyFile = React.useCallback(() => {
if (!privateKey || !publicKey) return;
if (!privateKey || !publicKey) {
return;
}
const decodedPrivateKey = decodeKeyData(privateKey);
const decodedPublicKey = decodeKeyData(publicKey);
@@ -116,7 +118,7 @@ export const PkiBackupDialog = ({
document.body.removeChild(link);
closeDialog();
URL.revokeObjectURL(url);
}, [decodeKeyData, privateKey, publicKey, closeDialog, t]);
}, [decodeKeyData, privateKey, publicKey, closeDialog, t, getMyNode]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -124,9 +126,7 @@ export const PkiBackupDialog = ({
<DialogClose />
<DialogHeader>
<DialogTitle>{t("pkiBackup.title")}</DialogTitle>
<DialogDescription>
{t("pkiBackup.secureBackup")}
</DialogDescription>
<DialogDescription>{t("pkiBackup.secureBackup")}</DialogDescription>
<DialogDescription>
<span className="font-bold break-before-auto">
{t("pkiBackup.loseKeysWarning")}
@@ -34,8 +34,7 @@ export const PkiRegenerateDialog = ({
const { t } = useTranslation("dialog");
const dialogText = {
title: text.title || t("pkiRegenerate.title"),
description: text.description ||
t("pkiRegenerate.description"),
description: text.description || t("pkiRegenerate.description"),
button: text.button || t("button.regenerate"),
};
return (
@@ -44,9 +43,7 @@ export const PkiRegenerateDialog = ({
<DialogClose />
<DialogHeader>
<DialogTitle>{dialogText.title}</DialogTitle>
<DialogDescription>
{dialogText.description}
</DialogDescription>
<DialogDescription>{dialogText.description}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
@@ -1,5 +1,4 @@
import { create, toBinary } from "@bufbuild/protobuf";
import { Checkbox } from "../UI/Checkbox/index.tsx";
import {
Dialog,
DialogClose,
@@ -14,8 +13,9 @@ import { Label } from "@components/UI/Label.tsx";
import { Protobuf, type Types } from "@meshtastic/core";
import { fromByteArray } from "base64-js";
import { useEffect, useMemo, useState } from "react";
import { QRCode } from "react-qrcode-logo";
import { useTranslation } from "react-i18next";
import { QRCode } from "react-qrcode-logo";
import { Checkbox } from "../UI/Checkbox/index.tsx";
export interface QRDialogProps {
open: boolean;
@@ -67,9 +67,7 @@ export const QRDialog = ({
<DialogClose />
<DialogHeader>
<DialogTitle>{t("qr.title")}</DialogTitle>
<DialogDescription>
{t("qr.description")}
</DialogDescription>
<DialogDescription>{t("qr.description")}</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="flex gap-3 px-4 py-5 sm:p-6">
@@ -81,12 +79,10 @@ export const QRDialog = ({
? channel.settings.name
: channel.role === Protobuf.Channel.Channel_Role.PRIMARY
? t("page.broadcastLabel", { ns: "channels" })
: `${
t("page.channelIndex", {
: `${t("page.channelIndex", {
ns: "channels",
index: channel.index,
})
}${channel.index}`}
})}${channel.index}`}
</Label>
<Checkbox
key={channel.index}
@@ -94,9 +90,7 @@ export const QRDialog = ({
onChange={() => {
if (selectedChannels.includes(channel.index)) {
setSelectedChannels(
selectedChannels.filter((c) =>
c !== channel.index
),
selectedChannels.filter((c) => c !== channel.index),
);
} else {
setSelectedChannels([
@@ -140,10 +134,7 @@ export const QRDialog = ({
</div>
<DialogFooter>
<Label>{t("qr.sharableUrl")}</Label>
<Input
value={qrCodeUrl}
disabled
/>
<Input value={qrCodeUrl} disabled />
</DialogFooter>
</DialogContent>
</Dialog>
@@ -10,18 +10,15 @@ import {
import { Input } from "@components/UI/Input.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { RefreshCwIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useState } from "react";
import { useTranslation } from "react-i18next";
export interface RebootDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const RebootDialog = ({
open,
onOpenChange,
}: RebootDialogProps) => {
export const RebootDialog = ({ open, onOpenChange }: RebootDialogProps) => {
const { t } = useTranslation("dialog");
const { connection } = useDevice();
@@ -32,12 +29,8 @@ export const RebootDialog = ({
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>
{t("reboot.title")}
</DialogTitle>
<DialogDescription>
{t("reboot.description")}
</DialogDescription>
<DialogTitle>{t("reboot.title")}</DialogTitle>
<DialogDescription>{t("reboot.description")}</DialogDescription>
</DialogHeader>
<div className="flex gap-2 p-4">
<Input
@@ -1,13 +1,13 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { RebootOTADialog } from "./RebootOTADialog.tsx";
import {
import type {
ButtonHTMLAttributes,
ClassAttributes,
InputHTMLAttributes,
ReactNode,
} from "react";
import { JSX } from "react/jsx-runtime";
import type { JSX } from "react/jsx-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { RebootOTADialog } from "./RebootOTADialog.tsx";
const rebootOtaMock = vi.fn();
let mockConnection: { rebootOta: (delay: number) => void } | undefined = {
@@ -25,10 +25,9 @@ vi.mock("@components/UI/Button.tsx", async () => {
return {
...actual,
Button: (
props:
& JSX.IntrinsicAttributes
& ClassAttributes<HTMLButtonElement>
& ButtonHTMLAttributes<HTMLButtonElement>,
props: JSX.IntrinsicAttributes &
ClassAttributes<HTMLButtonElement> &
ButtonHTMLAttributes<HTMLButtonElement>,
) => <button {...props} />,
};
});
@@ -38,10 +37,9 @@ vi.mock("@components/UI/Input.tsx", async () => {
return {
...actual,
Input: (
props:
& JSX.IntrinsicAttributes
& ClassAttributes<HTMLInputElement>
& InputHTMLAttributes<HTMLInputElement>,
props: JSX.IntrinsicAttributes &
ClassAttributes<HTMLInputElement> &
InputHTMLAttributes<HTMLInputElement>,
) => <input {...props} />,
};
});
@@ -76,8 +74,9 @@ describe("RebootOTADialog", () => {
it("renders dialog with default input value", () => {
render(<RebootOTADialog open onOpenChange={() => {}} />);
expect(screen.getByPlaceholderText(/enter delay/i)).toHaveValue(5);
expect(screen.getByRole("heading", { name: /schedule reboot/i, level: 1 }))
.toBeInTheDocument();
expect(
screen.getByRole("heading", { name: /schedule reboot/i, level: 1 }),
).toBeInTheDocument();
expect(screen.getByText(/reboot to ota mode now/i)).toBeInTheDocument();
});
@@ -1,5 +1,3 @@
import { useState } from "react";
import { ClockIcon, RefreshCwIcon } from "lucide-react";
import { Button } from "@components/UI/Button.tsx";
import {
Dialog,
@@ -11,6 +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 { useState } from "react";
import { useTranslation } from "react-i18next";
export interface RebootOTADialogProps {
@@ -20,9 +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);
@@ -39,13 +40,15 @@ export const RebootOTADialog = (
setInputValue(val);
const parsed = Number(val);
if (!isNaN(parsed) && parsed > 0) {
if (!Number.isNaN(parsed) && parsed > 0) {
setTime(parsed);
}
};
const handleRebootWithTimeout = async () => {
if (!connection) return;
if (!connection) {
return;
}
setIsScheduled(true);
const delay = time > 0 ? time : DEFAULT_REBOOT_DELAY;
@@ -63,7 +66,9 @@ export const RebootOTADialog = (
};
const handleInstantReboot = async () => {
if (!connection) return;
if (!connection) {
return;
}
await connection.rebootOta(DEFAULT_REBOOT_DELAY);
onOpenChange(false);
@@ -74,12 +79,8 @@ export const RebootOTADialog = (
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>
{t("rebootOta.title")}
</DialogTitle>
<DialogDescription>
{t("rebootOta.description")}
</DialogDescription>
<DialogTitle>{t("rebootOta.title")}</DialogTitle>
<DialogDescription>{t("rebootOta.description")}</DialogDescription>
</DialogHeader>
<div className="flex gap-2 p-2 items-center relative">
@@ -1,9 +1,9 @@
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 { render } from "@testing-library/react";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
import { RefreshKeysDialog } from "./RefreshKeysDialog.tsx";
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
vi.mock("@core/stores/messageStore");
vi.mock("./useRefreshKeysDialog");
@@ -12,8 +12,10 @@ const mockUseMessageStore = vi.mocked(useMessageStore);
const mockUseRefreshKeysDialog = vi.mocked(useRefreshKeysDialog);
const getInitialState = () =>
useDeviceStore.getInitialState?.() ??
{ devices: new Map(), remoteDevices: new Map() };
useDeviceStore.getInitialState?.() ?? {
devices: new Map(),
remoteDevices: new Map(),
};
beforeEach(() => {
useDeviceStore.setState(getInitialState(), true);
@@ -31,7 +33,9 @@ test("does not render dialog if no error exists for active chat", () => {
useDeviceStore.getState().addDevice(deviceId);
const currentDeviceState = useDeviceStore.getState().getDevice(deviceId);
if (!currentDeviceState) throw new Error("Device not found");
if (!currentDeviceState) {
throw new Error("Device not found");
}
mockUseMessageStore.mockReturnValue({ activeChat: activeChatNum });
mockUseRefreshKeysDialog.mockReturnValue({
@@ -1,3 +1,4 @@
import { Button } from "@components/UI/Button.tsx";
import {
Dialog,
DialogClose,
@@ -5,21 +6,21 @@ import {
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { Button } from "@components/UI/Button.tsx";
import { LockKeyholeOpenIcon } from "lucide-react";
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useTranslation } from "react-i18next";
import { useMessageStore } from "@core/stores/messageStore/index.ts";
import { LockKeyholeOpenIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.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();
@@ -40,9 +41,9 @@ export const RefreshKeysDialog = (
}),
description: `${t("refreshKeys.description.unableToSendDmPrefix")}${
nodeWithError?.user?.longName ?? ""
} (${nodeWithError?.user?.shortName ?? ""})${
t("refreshKeys.description.keyMismatchReasonSuffix")
}`,
} (${nodeWithError?.user?.shortName ?? ""})${t(
"refreshKeys.description.keyMismatchReasonSuffix",
)}`,
};
return (
@@ -69,9 +70,7 @@ export const RefreshKeysDialog = (
<p className="font-bold mb-0.5">
{t("refreshKeys.label.acceptNewKeys")}
</p>
<p>
{t("refreshKeys.description.acceptNewKeys")}
</p>
<p>{t("refreshKeys.description.acceptNewKeys")}</p>
</div>
<Button
variant="default"
@@ -1,6 +1,6 @@
import { useCallback } from "react";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useMessageStore } from "@core/stores/messageStore/index.ts";
import { useCallback } from "react";
export function useRefreshKeysDialog() {
const { removeNode, setDialogOpen, clearNodeError, getNodeError } =
@@ -1,5 +1,3 @@
import { useAppStore } from "../../core/stores/appStore.ts";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Button } from "@components/UI/Button.tsx";
import {
Dialog,
@@ -11,7 +9,9 @@ import {
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { Label } from "@components/UI/Label.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useTranslation } from "react-i18next";
import { useAppStore } from "../../core/stores/appStore.ts";
export interface RemoveNodeDialogProps {
open: boolean;
@@ -38,9 +38,7 @@ export const RemoveNodeDialog = ({
<DialogClose />
<DialogHeader>
<DialogTitle>{t("removeNode.title")}</DialogTitle>
<DialogDescription>
{t("removeNode.description")}
</DialogDescription>
<DialogDescription>{t("removeNode.description")}</DialogDescription>
</DialogHeader>
<div className="gap-4">
<form onSubmit={onSubmit}>
@@ -10,18 +10,15 @@ 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";
import { useTranslation } from "react-i18next";
export interface ShutdownDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const ShutdownDialog = ({
open,
onOpenChange,
}: ShutdownDialogProps) => {
export const ShutdownDialog = ({ open, onOpenChange }: ShutdownDialogProps) => {
const { t } = useTranslation("dialog");
const { connection } = useDevice();
@@ -32,12 +29,8 @@ export const ShutdownDialog = ({
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>
{t("shutdown.title")}
</DialogTitle>
<DialogDescription>
{t("shutdown.description")}
</DialogDescription>
<DialogTitle>{t("shutdown.title")}</DialogTitle>
<DialogDescription>{t("shutdown.description")}</DialogDescription>
</DialogHeader>
<div className="flex gap-2 p-4">
@@ -1,4 +1,9 @@
import { useDevice } from "@core/stores/deviceStore.ts";
import type { Protobuf, Types } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { useTranslation } from "react-i18next";
import { TraceRoute } from "../PageComponents/Messages/TraceRoute.tsx";
import {
Dialog,
DialogClose,
@@ -7,11 +12,6 @@ import {
DialogHeader,
DialogTitle,
} from "../UI/Dialog.tsx";
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;
@@ -31,9 +31,11 @@ export const TracerouteResponseDialog = ({
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 ??
const fromLongName =
from?.user?.longName ??
(from ? `!${numberToHexUnpadded(from?.num)}` : t("unknown.shortName"));
const fromShortName = from?.user?.shortName ??
const fromShortName =
from?.user?.shortName ??
(from
? `${numberToHexUnpadded(from?.num).substring(0, 4)}`
: t("unknown.shortName"));
@@ -1,3 +1,5 @@
import { Button } from "@components/UI/Button.tsx";
import { Checkbox } from "@components/UI/Checkbox/index.tsx";
import {
Dialog,
DialogClose,
@@ -8,11 +10,9 @@ import {
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { Link } from "@components/UI/Typography/Link.tsx";
import { Checkbox } from "@components/UI/Checkbox/index.tsx";
import { Button } from "@components/UI/Button.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useState } from "react";
import { eventBus } from "@core/utils/eventBus.ts";
import { useState } from "react";
import { useTranslation } from "react-i18next";
export interface RouterRoleDialogProps {
@@ -20,9 +20,10 @@ export interface RouterRoleDialogProps {
onOpenChange: (open: boolean) => void;
}
export const UnsafeRolesDialog = (
{ open, onOpenChange }: RouterRoleDialogProps,
) => {
export const UnsafeRolesDialog = ({
open,
onOpenChange,
}: RouterRoleDialogProps) => {
const { t } = useTranslation("dialog");
const [confirmState, setConfirmState] = useState(false);
const { setDialogOpen } = useDevice();
@@ -1,3 +1,9 @@
import {
UNSAFE_ROLES,
useUnsafeRolesDialog,
} from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
import { eventBus } from "@core/utils/eventBus.ts";
import { renderHook } from "@testing-library/react";
import {
afterEach,
beforeEach,
@@ -7,18 +13,11 @@ import {
type Mock,
vi,
} from "vitest";
import { renderHook } from "@testing-library/react";
import {
UNSAFE_ROLES,
useUnsafeRolesDialog,
} from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
import { eventBus } from "@core/utils/eventBus.ts";
const mockNavigate = vi.fn();
vi.mock("@tanstack/react-router", async (importOriginal) => {
const actual = await importOriginal<
typeof import("@tanstack/react-router")
>();
const actual =
await importOriginal<typeof import("@tanstack/react-router")>();
return {
...actual,
useNavigate: () => mockNavigate,
@@ -75,9 +74,8 @@ describe("useUnsafeRolesDialog", () => {
const { result } = renderUnsafeRolesHook();
const safeRole = "SAFE_ROLE";
const validationResult = await result.current.validateRoleSelection(
safeRole,
);
const validationResult =
await result.current.validateRoleSelection(safeRole);
expect(validationResult).toBe(true);
expect(mockDevice.setDialogOpen).not.toHaveBeenCalled();
@@ -151,9 +149,8 @@ describe("useUnsafeRolesDialog", () => {
mockDevice.setDialogOpen.mockClear();
(eventBus.on as Mock).mockClear();
const validationPromise = result.current.validateRoleSelection(
unsafeRole,
);
const validationPromise =
result.current.validateRoleSelection(unsafeRole);
expect(mockDevice.setDialogOpen).toHaveBeenCalledWith(
"unsafeRoles",
@@ -1,9 +1,9 @@
import { useCallback } from "react";
import { eventBus } from "@core/utils/eventBus.ts";
import { useDevice } from "@core/stores/deviceStore.ts";
import { eventBus } from "@core/utils/eventBus.ts";
import { useCallback } from "react";
export const UNSAFE_ROLES = ["ROUTER", "ROUTER_LATE", "REPEATER"];
export type UnsafeRole = typeof UNSAFE_ROLES[number];
export type UnsafeRole = (typeof UNSAFE_ROLES)[number];
export const useUnsafeRolesDialog = () => {
const { setDialogOpen } = useDevice();
@@ -21,9 +21,11 @@ export const useUnsafeRolesDialog = () => {
setDialogOpen("unsafeRoles", true);
return new Promise((resolve) => {
const handleResponse = (
{ action }: { action: "confirm" | "dismiss" },
) => {
const handleResponse = ({
action,
}: {
action: "confirm" | "dismiss";
}) => {
eventBus.off("dialog:unsafeRoles", handleResponse);
resolve(action === "confirm");
};
@@ -1,10 +1,15 @@
import { createZodResolver } from "@components/Form/createZodResolver.ts";
import {
DynamicFormField,
type FieldProps,
} from "@components/Form/DynamicFormField.tsx";
import { FieldWrapper } from "@components/Form/FormWrapper.tsx";
import { Button } from "@components/UI/Button.tsx";
import { Heading } from "@components/UI/Typography/Heading.tsx";
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
import { useAppStore } from "@core/stores/appStore.ts";
import { dotPaths } from "@core/utils/dotPath.ts";
import { useEffect } from "react";
import {
type Control,
type DefaultValues,
@@ -13,16 +18,11 @@ import {
get,
type Path,
type SubmitHandler,
useForm,
type UseFormReturn,
useForm,
} from "react-hook-form";
import { Heading } from "@components/UI/Typography/Heading.tsx";
import { ZodType } from "zod/v4";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { createZodResolver } from "@components/Form/createZodResolver.ts";
import { useAppStore } from "@core/stores/appStore.ts";
import { dotPaths } from "@core/utils/dotPath.ts";
import type { ZodType } from "zod/v4";
interface DisabledBy<T> {
fieldName: Path<T>;
@@ -86,16 +86,9 @@ export function DynamicForm<T extends FieldValues>({
formId,
}: DynamicFormProps<T>) {
const { t } = useTranslation();
const {
addError,
removeError,
} = useAppStore();
const { addError, removeError } = useAppStore();
let methods = propMethods;
if (!methods) {
methods = useForm<
T
>({
const internalMethods = useForm<T>({
mode: "onChange",
defaultValues: defaultValues,
resolver: validationSchema
@@ -105,22 +98,22 @@ export function DynamicForm<T extends FieldValues>({
resetOptions: { keepDefaultValues: true },
values,
});
}
const methods = propMethods ?? internalMethods;
const { handleSubmit, control, getValues, formState, getFieldState } =
methods;
useEffect(() => {
if (!propMethods) {
onFormInit?.(methods);
onFormInit?.(internalMethods);
}
}, [onFormInit, propMethods, methods]);
}, [onFormInit, propMethods, internalMethods]);
useEffect(() => {
const errorKeys = Object.keys(formState.errors);
if (formId) {
if (
errorKeys.length === 0
) {
if (errorKeys.length === 0) {
dotPaths(getValues()).forEach((key) => {
removeError(key);
});
@@ -132,19 +125,27 @@ export function DynamicForm<T extends FieldValues>({
addError(formId, "");
}
}
}, [formState.errors]);
}, [formState.errors, addError, formId, getValues, removeError]);
const isDisabled = (
disabledBy?: DisabledBy<T>[],
disabled?: boolean,
): boolean => {
if (disabled) return true;
if (!disabledBy) return false;
if (disabled) {
return true;
}
if (!disabledBy) {
return false;
}
return disabledBy.some((field) => {
const value = getValues(field.fieldName);
if (value === "always") return true;
if (typeof value === "boolean") return field.invert ? value : !value;
if (value === "always") {
return true;
}
if (typeof value === "boolean") {
return field.invert ? value : !value;
}
if (typeof value === "number") {
return field.invert
? field.selector !== value
@@ -181,14 +182,16 @@ export function DynamicForm<T extends FieldValues>({
fieldName={field.name}
description={field.description}
valid={!error}
validationText={error
validationText={
error
? String(
t([`formValidation.${error.type}`, error.message], {
returnObjects: false,
...error.params,
}),
)
: ""}
: ""
}
>
<DynamicFormField
field={field}
@@ -203,11 +206,7 @@ export function DynamicForm<T extends FieldValues>({
</div>
))}
{hasSubmitButton && (
<Button
type="submit"
variant="outline"
disabled={!formState.isValid}
>
<Button type="submit" variant="outline" disabled={!formState.isValid}>
{t("button.submit")}
</Button>
)}
@@ -1,7 +1,3 @@
import {
type MultiSelectFieldProps,
MultiSelectInput,
} from "./FormMultiSelect.tsx";
import {
GenericInput,
type InputFieldProps,
@@ -19,6 +15,10 @@ import {
ToggleInput,
} from "@components/Form/FormToggle.tsx";
import type { Control, FieldValues } from "react-hook-form";
import {
type MultiSelectFieldProps,
MultiSelectInput,
} from "./FormMultiSelect.tsx";
export type FieldProps<T> =
| InputFieldProps<T>
+10 -5
View File
@@ -58,7 +58,9 @@ export function GenericInput<T extends FieldValues>({
return;
}
if (field.inputChange) field.inputChange(e);
if (field.inputChange) {
field.inputChange(e);
}
controllerField.onChange(
field.type === "number"
@@ -76,14 +78,17 @@ export function GenericInput<T extends FieldValues>({
<Input
type={field.type}
step={field.properties?.step}
value={field.type === "number"
value={
field.type === "number"
? String(controllerField.value)
: controllerField.value}
: controllerField.value
}
id={field.name}
onChange={handleInputChange}
showCopyButton={field.properties?.showCopyButton}
showPasswordToggle={field.properties?.showPasswordToggle ||
field.type === "password"}
showPasswordToggle={
field.properties?.showPasswordToggle || field.type === "password"
}
className={field.properties?.className}
{...restProperties}
disabled={disabled}
@@ -2,11 +2,11 @@ import type {
BaseFormBuilderProps,
GenericFormElementProps,
} from "@components/Form/DynamicForm.tsx";
import type { FLAGS_CONFIG } from "@core/hooks/usePositionFlags.ts";
import { cn } from "@core/utils/cn.ts";
import type { FieldValues } from "react-hook-form";
import { useTranslation } from "react-i18next";
import type { FLAGS_CONFIG } from "@core/hooks/usePositionFlags.ts";
import { MultiSelect, MultiSelectItem } from "../UI/MultiSelect.tsx";
import { cn } from "@core/utils/cn.ts";
export interface MultiSelectFieldProps<T> extends BaseFormBuilderProps<T> {
type: "multiSelect";
@@ -15,9 +15,7 @@ export interface MultiSelectFieldProps<T> extends BaseFormBuilderProps<T> {
isChecked: (name: string) => boolean;
value: string[];
properties: BaseFormBuilderProps<T>["properties"] & {
enumValue:
| { [s: string]: string | number }
| typeof FLAGS_CONFIG;
enumValue: { [s: string]: string | number } | typeof FLAGS_CONFIG;
formatEnumName?: boolean;
};
}
@@ -39,7 +37,7 @@ export function MultiSelectInput<T extends FieldValues>({
([key, configOrValue]) => {
if (isNewConfigStructure) {
const config =
configOrValue as typeof FLAGS_CONFIG[keyof typeof FLAGS_CONFIG];
configOrValue as (typeof FLAGS_CONFIG)[keyof typeof FLAGS_CONFIG];
return {
key,
display: t(config.i18nKey),
@@ -2,11 +2,11 @@ import type {
BaseFormBuilderProps,
GenericFormElementProps,
} from "@components/Form/DynamicForm.tsx";
import type { ButtonVariant } from "../UI/Button.tsx";
import { Generator } from "@components/UI/Generator.tsx";
import { Controller, type FieldValues, useFormContext } from "react-hook-form";
import { usePasswordVisibilityToggle } from "@core/hooks/usePasswordVisibilityToggle.ts";
import { useEffect } from "react";
import { Controller, type FieldValues, useFormContext } from "react-hook-form";
import type { ButtonVariant } from "../UI/Button.tsx";
export interface PasswordGeneratorProps<T> extends BaseFormBuilderProps<T> {
type: "passwordGenerator";
@@ -38,24 +38,22 @@ export function PasswordGenerator<T extends FieldValues>({
useEffect(() => {
trigger(field.name);
}, [field.devicePSKBitCount, field.name, trigger]);
}, [field.name, trigger]);
return (
<Controller
name={field.name}
control={control}
render={(
{
field: { value, onChange, ...rest },
},
) => (
render={({ field: { value, onChange, ...rest } }) => (
<Generator
type={field.hide && !isVisible ? "password" : "text"}
id={field.id}
devicePSKBitCount={field.devicePSKBitCount}
bits={field.bits}
inputChange={(e) => {
if (field.inputChange) field.inputChange(e);
if (field.inputChange) {
field.inputChange(e);
}
onChange(e);
}}
selectChange={field.selectChange ?? (() => {})}
@@ -9,8 +9,8 @@ import {
SelectTrigger,
SelectValue,
} from "@components/UI/Select.tsx";
import { type FieldValues, useController } from "react-hook-form";
import { cn } from "@core/utils/cn.ts";
import { type FieldValues, useController } from "react-hook-form";
export interface SelectFieldProps<T> extends BaseFormBuilderProps<T> {
type: "select";
@@ -73,10 +73,14 @@ export function SelectInput<T extends FieldValues>({
if (field.validate) {
const isValid = await field.validate(selectedKey);
if (!isValid) return;
if (!isValid) {
return;
}
}
if (field.selectChange) field.selectChange(newValue, selectedKey);
if (field.selectChange) {
field.selectChange(newValue, selectedKey);
}
onChange(Number.parseInt(newValue));
};
@@ -3,8 +3,8 @@ import type {
GenericFormElementProps,
} from "@components/Form/DynamicForm.tsx";
import { Switch } from "@components/UI/Switch.tsx";
import { Controller, type FieldValues } from "react-hook-form";
import { cn } from "@core/utils/cn.ts";
import { Controller, type FieldValues } from "react-hook-form";
export interface ToggleFieldProps<T> extends BaseFormBuilderProps<T> {
type: "toggle";
@@ -22,9 +22,7 @@ export function ToggleInput<T extends FieldValues>({
<Controller
name={field.name}
control={control}
render={(
{ field: { value, onChange, ...rest } },
) => (
render={({ field: { value, onChange, ...rest } }) => (
<Switch
checked={value}
onCheckedChange={(v) => {
@@ -1,4 +1,3 @@
import type { ZodType } from "zod/v4";
import type {
FieldError,
FieldValues,
@@ -6,6 +5,7 @@ import type {
ResolverOptions,
ResolverResult,
} from "react-hook-form";
import type { ZodType } from "zod/v4";
export function createZodResolver<T extends FieldValues>(
schema: ZodType<T, unknown>,
@@ -32,7 +32,8 @@ export function createZodResolver<T extends FieldValues>(
const { path, code, message, ...params } = issue;
const key = path.join(".");
const suffix = "format" in params
const suffix =
"format" in params
? params.format
: "origin" in params
? params.origin
@@ -40,10 +41,9 @@ export function createZodResolver<T extends FieldValues>(
? params.expected
: "";
const newCode = code.replace(
/_([a-z])/g,
(_, char) => char.toUpperCase(),
) + (suffix ? `.${suffix}` : "");
const newCode =
code.replace(/_([a-z])/g, (_, char) => char.toUpperCase()) +
(suffix ? `.${suffix}` : "");
const fieldError: FieldError & { params?: Record<string, unknown> } = {
type: newCode,
@@ -11,6 +11,5 @@ export const KeyBackupReminder = () => {
onAccept: () => setDialogOpen("pkiBackup", true),
enabled: true,
});
// deno-lint-ignore jsx-no-useless-fragment
return <></>;
return null;
};
@@ -1,7 +1,10 @@
import { Check, Languages } from "lucide-react";
import { useTranslation } from "react-i18next";
import { LangCode, supportedLanguages } from "@app/i18n-config.ts";
import { type LangCode, supportedLanguages } from "@app/i18n-config.ts";
import useLang from "@core/hooks/useLang.ts";
import { cn } from "@core/utils/cn.ts";
import { Check, Languages } from "lucide-react";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "./UI/Button.tsx";
import {
DropdownMenu,
DropdownMenuContent,
@@ -9,8 +12,6 @@ import {
DropdownMenuTrigger,
} from "./UI/DropdownMenu.tsx";
import { Subtle } from "./UI/Typography/Subtle.tsx";
import { cn } from "@core/utils/cn.ts";
import { Button } from "./UI/Button.tsx";
interface LanguageSwitcherProps {
disableHover?: boolean;
@@ -22,9 +23,12 @@ export default function LanguageSwitcher({
const { i18n } = useTranslation("ui");
const { set: setLanguage, currentLanguage } = useLang();
const handleLanguageChange = async (languageCode: LangCode) => {
const handleLanguageChange = useCallback(
async (languageCode: LangCode) => {
await setLanguage(languageCode, true);
};
},
[setLanguage],
);
return (
<DropdownMenu>
+8 -8
View File
@@ -1,25 +1,27 @@
import { useTheme } from "@core/hooks/useTheme.ts";
import { useEffect, useRef } from "react";
import MapGl, {
AttributionControl,
type MapRef,
NavigationControl,
ScaleControl,
} from "react-map-gl/maplibre";
import { useTheme } from "@core/hooks/useTheme.ts";
import { useEffect, useRef } from "react";
interface MapProps {
children?: React.ReactNode;
onLoad?: (map: MapRef) => void;
}
export const Map = ({ children, onLoad }: MapProps) => {
export const BaseMap = ({ children, onLoad }: MapProps) => {
const { theme } = useTheme();
const darkMode = theme === "dark";
const mapRef = useRef<MapRef | null>(null);
useEffect(() => {
const map = mapRef.current;
if (map && onLoad) onLoad(map);
if (map && onLoad) {
onLoad(map);
}
}, [onLoad]);
return (
@@ -45,14 +47,12 @@ export const Map = ({ children, onLoad }: MapProps) => {
}}
/>
{/* { Disabled for now until we can use i18n for the geolocate control} */}
{
/* <GeolocateControl
{/* <GeolocateControl
position="top-right"
i18nIsDynamicList
positionOptions={{ enableHighAccuracy: true }}
trackUserLocation
/> */
}
/> */}
<NavigationControl position="top-right" showCompass={false} />
<ScaleControl />
{children}
@@ -1,5 +1,6 @@
import { makeChannelSchema } from "@app/validation/channel.ts";
import { create } from "@bufbuild/protobuf";
import { PkiRegenerateDialog } from "@components/Dialog/PkiRegenerateDialog.tsx";
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import { useToast } from "@core/hooks/useToast.ts";
import { useDevice } from "@core/stores/deviceStore.ts";
@@ -8,8 +9,7 @@ import { fromByteArray, toByteArray } from "base64-js";
import cryptoRandomString from "crypto-random-string";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { PkiRegenerateDialog } from "@components/Dialog/PkiRegenerateDialog.tsx";
import { infer as zodInfer } from "zod/v4";
import type { infer as zodInfer } from "zod/v4";
export interface SettingsPanelProps {
channel: Protobuf.Channel.Channel;
@@ -20,9 +20,8 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
const { t } = useTranslation(["channels", "ui", "dialog"]);
const { toast } = useToast();
const [preSharedDialogOpen, setPreSharedDialogOpen] = useState<boolean>(
false,
);
const [preSharedDialogOpen, setPreSharedDialogOpen] =
useState<boolean>(false);
const [pass, setPass] = useState<string>(
fromByteArray(channel?.settings?.psk ?? new Uint8Array(0)),
);
@@ -30,12 +29,9 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
channel?.settings?.psk.length ?? 16,
);
const ChannelValidationSchema = useMemo(
() => {
const ChannelValidationSchema = useMemo(() => {
return makeChannelSchema(byteCount);
},
[byteCount],
);
}, [byteCount]);
type ChannelValidation = zodInfer<typeof ChannelValidationSchema>;
@@ -52,10 +48,12 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
},
});
connection?.setChannel(channel).then(() => {
console.debug(t("toast.savedChannel.title", {
console.debug(
t("toast.savedChannel.title", {
ns: "ui",
channelName: channel.settings?.name,
}));
}),
);
toast({
title: t("toast.savedChannel.title", {
ns: "ui",
@@ -127,7 +125,8 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
disabled: channel.index === 0,
description: t("role.description"),
properties: {
enumValue: channel.index === 0
enumValue:
channel.index === 0
? { [t("role.options.primary")]: 1 }
: {
[t("role.options.disabled")]: 0,
@@ -182,78 +181,35 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
label: t("positionPrecision.label"),
description: t("positionPrecision.description"),
properties: {
enumValue: config.display?.units === 0
enumValue:
config.display?.units === 0
? {
[t("positionPrecision.options.none")]: 0,
[
t("positionPrecision.options.metric_km23")
]: 10,
[
t("positionPrecision.options.metric_km12")
]: 11,
[
t("positionPrecision.options.metric_km5_8")
]: 12,
[
t("positionPrecision.options.metric_km2_9")
]: 13,
[
t("positionPrecision.options.metric_km1_5")
]: 14,
[
t("positionPrecision.options.metric_m700")
]: 15,
[
t("positionPrecision.options.metric_m350")
]: 16,
[
t("positionPrecision.options.metric_m200")
]: 17,
[
t("positionPrecision.options.metric_m90")
]: 18,
[
t("positionPrecision.options.metric_m50")
]: 19,
[
t("positionPrecision.options.precise")
]: 32,
[t("positionPrecision.options.metric_km23")]: 10,
[t("positionPrecision.options.metric_km12")]: 11,
[t("positionPrecision.options.metric_km5_8")]: 12,
[t("positionPrecision.options.metric_km2_9")]: 13,
[t("positionPrecision.options.metric_km1_5")]: 14,
[t("positionPrecision.options.metric_m700")]: 15,
[t("positionPrecision.options.metric_m350")]: 16,
[t("positionPrecision.options.metric_m200")]: 17,
[t("positionPrecision.options.metric_m90")]: 18,
[t("positionPrecision.options.metric_m50")]: 19,
[t("positionPrecision.options.precise")]: 32,
}
: {
[t("positionPrecision.options.none")]: 0,
[
t("positionPrecision.options.imperial_mi15")
]: 10,
[
t("positionPrecision.options.imperial_mi7_3")
]: 11,
[
t("positionPrecision.options.imperial_mi3_6")
]: 12,
[
t("positionPrecision.options.imperial_mi1_8")
]: 13,
[
t("positionPrecision.options.imperial_mi0_9")
]: 14,
[
t("positionPrecision.options.imperial_mi0_5")
]: 15,
[
t("positionPrecision.options.imperial_mi0_2")
]: 16,
[
t("positionPrecision.options.imperial_ft600")
]: 17,
[
t("positionPrecision.options.imperial_ft300")
]: 18,
[
t("positionPrecision.options.imperial_ft150")
]: 19,
[
t("positionPrecision.options.precise")
]: 32,
[t("positionPrecision.options.imperial_mi15")]: 10,
[t("positionPrecision.options.imperial_mi7_3")]: 11,
[t("positionPrecision.options.imperial_mi3_6")]: 12,
[t("positionPrecision.options.imperial_mi1_8")]: 13,
[t("positionPrecision.options.imperial_mi0_9")]: 14,
[t("positionPrecision.options.imperial_mi0_5")]: 15,
[t("positionPrecision.options.imperial_mi0_2")]: 16,
[t("positionPrecision.options.imperial_ft600")]: 17,
[t("positionPrecision.options.imperial_ft300")]: 18,
[t("positionPrecision.options.imperial_ft150")]: 19,
[t("positionPrecision.options.precise")]: 32,
},
},
},
@@ -1,3 +1,4 @@
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import {
type BluetoothValidation,
BluetoothValidationSchema,
@@ -8,14 +9,16 @@ import {
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface BluetoothConfigProps {
onFormInit: DynamicFormFormInit<BluetoothValidation>;
}
export const Bluetooth = ({ onFormInit }: BluetoothConfigProps) => {
useWaitForConfig({ configCase: "bluetooth" });
const { config, setWorkingConfig, getEffectiveConfig, removeWorkingConfig } =
useDevice();
const { t } = useTranslation("deviceConfig");
@@ -1,37 +0,0 @@
import {
useDevice,
ValidConfigType,
ValidModuleConfigType,
} from "@core/stores/deviceStore.ts";
import { useEffect, useState } from "react";
export function ConfigSuspender({
configCase,
moduleConfigCase,
children,
}: {
configCase?: ValidConfigType;
moduleConfigCase?: ValidModuleConfigType;
children: React.ReactNode;
}) {
const { config, moduleConfig } = useDevice();
let cfg = undefined;
if (configCase) {
cfg = config[configCase];
} else if (moduleConfigCase) {
cfg = moduleConfig[moduleConfigCase];
} else {
return children;
}
const [ready, setReady] = useState(() => cfg !== undefined);
useEffect(() => {
if (cfg !== undefined) setReady(true);
}, [cfg]);
if (!ready) throw new Promise(() => {}); // triggers suspense fallback
return children;
}
@@ -1,22 +1,25 @@
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import {
type DeviceValidation,
DeviceValidationSchema,
} from "@app/validation/config/device.ts";
import { create } from "@bufbuild/protobuf";
import { useUnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core";
import { useUnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
interface DeviceConfigProps {
onFormInit: DynamicFormFormInit<DeviceValidation>;
}
export const Device = ({ onFormInit }: DeviceConfigProps) => {
useWaitForConfig({ configCase: "device" });
const { config, setWorkingConfig, getEffectiveConfig, removeWorkingConfig } =
useDevice();
const { t } = useTranslation("deviceConfig");
@@ -113,8 +116,8 @@ export const Device = ({ onFormInit }: DeviceConfigProps) => {
properties: {
fieldLength: {
max: 64,
currentValueLength: getEffectiveConfig("device")?.tzdef
?.length,
currentValueLength:
getEffectiveConfig("device")?.tzdef?.length,
showCharacterCount: true,
},
},
@@ -1,3 +1,4 @@
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import {
type DisplayValidation,
DisplayValidationSchema,
@@ -8,14 +9,15 @@ import {
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface DisplayConfigProps {
onFormInit: DynamicFormFormInit<DisplayValidation>;
}
export const Display = ({ onFormInit }: DisplayConfigProps) => {
useWaitForConfig({ configCase: "display" });
const { config, setWorkingConfig, getEffectiveConfig, removeWorkingConfig } =
useDevice();
const { t } = useTranslation("deviceConfig");
@@ -1,3 +1,4 @@
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import {
type LoRaValidation,
LoRaValidationSchema,
@@ -8,14 +9,16 @@ import {
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface LoRaConfigProps {
onFormInit: DynamicFormFormInit<LoRaValidation>;
}
export const LoRa = ({ onFormInit }: LoRaConfigProps) => {
useWaitForConfig({ configCase: "lora" });
const { config, setWorkingConfig, getEffectiveConfig, removeWorkingConfig } =
useDevice();
const { t } = useTranslation("deviceConfig");
@@ -1,3 +1,4 @@
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import {
type NetworkValidation,
NetworkValidationSchema,
@@ -8,18 +9,20 @@ import {
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import {
convertIntToIpAddress,
convertIpAddressToInt,
} from "@core/utils/ip.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface NetworkConfigProps {
onFormInit: DynamicFormFormInit<NetworkValidation>;
}
export const Network = ({ onFormInit }: NetworkConfigProps) => {
useWaitForConfig({ configCase: "network" });
const { config, setWorkingConfig, getEffectiveConfig, removeWorkingConfig } =
useDevice();
const { t } = useTranslation("deviceConfig");
@@ -72,10 +75,12 @@ export const Network = ({ onFormInit }: NetworkConfigProps) => {
),
dns: convertIntToIpAddress(config.network?.ipv4Config?.dns ?? 0),
},
enabledProtocols: config.network?.enabledProtocols ??
enabledProtocols:
config.network?.enabledProtocols ??
Protobuf.Config.Config_NetworkConfig_ProtocolFlags.NO_BROADCAST,
}}
values={{
values={
{
...networkConfig,
ipv4Config: {
ip: convertIntToIpAddress(networkConfig?.ipv4Config?.ip ?? 0),
@@ -87,9 +92,11 @@ export const Network = ({ onFormInit }: NetworkConfigProps) => {
),
dns: convertIntToIpAddress(networkConfig?.ipv4Config?.dns ?? 0),
},
enabledProtocols: networkConfig?.enabledProtocols ??
enabledProtocols:
networkConfig?.enabledProtocols ??
Protobuf.Config.Config_NetworkConfig_ProtocolFlags.NO_BROADCAST,
} as NetworkValidation}
} as NetworkValidation
}
fieldGroups={[
{
label: t("network.title"),
@@ -1,7 +1,4 @@
import {
type FlagName,
usePositionFlags,
} from "@core/hooks/usePositionFlags.ts";
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import {
type PositionValidation,
PositionValidationSchema,
@@ -11,16 +8,22 @@ import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import {
type FlagName,
usePositionFlags,
} from "@core/hooks/usePositionFlags.ts";
import { useDevice } from "@core/stores/deviceStore.ts";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface PositionConfigProps {
onFormInit: DynamicFormFormInit<PositionValidation>;
}
export const Position = ({ onFormInit }: PositionConfigProps) => {
useWaitForConfig({ configCase: "position" });
const { setWorkingConfig, config, getEffectiveConfig, removeWorkingConfig } =
useDevice();
const { flagsValue, activeFlags, toggleFlag, getAllFlags } = usePositionFlags(
@@ -1,3 +1,4 @@
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import {
type PowerValidation,
PowerValidationSchema,
@@ -8,14 +9,16 @@ import {
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface PowerConfigProps {
onFormInit: DynamicFormFormInit<PowerValidation>;
}
export const Power = ({ onFormInit }: PowerConfigProps) => {
useWaitForConfig({ configCase: "power" });
const { setWorkingConfig, config, getEffectiveConfig, removeWorkingConfig } =
useDevice();
const { t } = useTranslation("deviceConfig");
@@ -1,30 +1,33 @@
import { PkiRegenerateDialog } from "@components/Dialog/PkiRegenerateDialog.tsx";
import { ManagedModeDialog } from "@components/Dialog/ManagedModeDialog.tsx";
import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useAppStore } from "@core/stores/appStore.ts";
import { getX25519PrivateKey, getX25519PublicKey } from "@core/utils/x25519.ts";
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import {
type ParsedSecurity,
type RawSecurity,
RawSecuritySchema,
} from "@app/validation/config/security.ts";
import { useEffect, useState } from "react";
import { create } from "@bufbuild/protobuf";
import { ManagedModeDialog } from "@components/Dialog/ManagedModeDialog.tsx";
import { PkiRegenerateDialog } from "@components/Dialog/PkiRegenerateDialog.tsx";
import { createZodResolver } from "@components/Form/createZodResolver.ts";
import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDevice } from "@core/stores/deviceStore.ts";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { getX25519PrivateKey, getX25519PublicKey } from "@core/utils/x25519.ts";
import { Protobuf } from "@meshtastic/core";
import { fromByteArray, toByteArray } from "base64-js";
import { useTranslation } from "react-i18next";
import { useEffect, useState } from "react";
import { type DefaultValues, useForm } from "react-hook-form";
import { createZodResolver } from "@components/Form/createZodResolver.ts";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { useTranslation } from "react-i18next";
interface SecurityConfigProps {
onFormInit: DynamicFormFormInit<RawSecurity>;
}
export const Security = ({ onFormInit }: SecurityConfigProps) => {
useWaitForConfig({ configCase: "security" });
const {
config,
setWorkingConfig,
@@ -43,19 +46,11 @@ export const Security = ({ onFormInit }: SecurityConfigProps) => {
privateKey: fromByteArray(
securityConfig?.privateKey ?? new Uint8Array(0),
),
publicKey: fromByteArray(
securityConfig?.publicKey ?? new Uint8Array(0),
),
publicKey: fromByteArray(securityConfig?.publicKey ?? new Uint8Array(0)),
adminKey: [
fromByteArray(
securityConfig?.adminKey?.at(0) ?? new Uint8Array(0),
),
fromByteArray(
securityConfig?.adminKey?.at(1) ?? new Uint8Array(0),
),
fromByteArray(
securityConfig?.adminKey?.at(2) ?? new Uint8Array(0),
),
fromByteArray(securityConfig?.adminKey?.at(0) ?? new Uint8Array(0)),
fromByteArray(securityConfig?.adminKey?.at(1) ?? new Uint8Array(0)),
fromByteArray(securityConfig?.adminKey?.at(2) ?? new Uint8Array(0)),
],
},
};
@@ -73,15 +68,15 @@ export const Security = ({ onFormInit }: SecurityConfigProps) => {
onFormInit?.(formMethods);
}, [onFormInit, formMethods]);
const [privateKeyDialogOpen, setPrivateKeyDialogOpen] = useState<boolean>(
false,
);
const [managedModeDialogOpen, setManagedModeDialogOpen] = useState<boolean>(
false,
);
const [privateKeyDialogOpen, setPrivateKeyDialogOpen] =
useState<boolean>(false);
const [managedModeDialogOpen, setManagedModeDialogOpen] =
useState<boolean>(false);
const onSubmit = (data: RawSecurity) => {
if (!formState.isReady) return;
if (!formState.isReady) {
return;
}
const payload: ParsedSecurity = {
...data,
@@ -1,19 +1,17 @@
import type { TabElementProps } from "../../Dialog/NewDeviceDialog.tsx";
import { Button } from "@components/UI/Button.tsx";
import { Mono } from "@components/generic/Mono.tsx";
import { Button } from "@components/UI/Button.tsx";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDeviceStore } from "@core/stores/deviceStore.ts";
import { useMessageStore } from "@core/stores/messageStore/index.ts";
import { subscribeAll } from "@core/subscriptions.ts";
import { randId } from "@core/utils/randId.ts";
import { TransportWebBluetooth } from "@meshtastic/transport-web-bluetooth";
import { MeshDevice } from "@meshtastic/core";
import { TransportWebBluetooth } from "@meshtastic/transport-web-bluetooth";
import { useCallback, useEffect, useState } from "react";
import { useMessageStore } from "@core/stores/messageStore/index.ts";
import { useTranslation } from "react-i18next";
import type { TabElementProps } from "../../Dialog/NewDeviceDialog.tsx";
export const BLE = (
{ closeDialog }: TabElementProps,
) => {
export const BLE = ({ closeDialog }: TabElementProps) => {
const [connectionInProgress, setConnectionInProgress] = useState(false);
const [bleDevices, setBleDevices] = useState<BluetoothDevice[]>([]);
const { addDevice } = useDeviceStore();
@@ -77,10 +75,12 @@ export const BLE = (
if (exists === -1) {
setBleDevices(bleDevices.concat(device));
}
}).catch((error: Error) => {
})
.catch((error: Error) => {
console.error("Error requesting device:", error);
setConnectionInProgress(false);
}).finally(() => {
})
.finally(() => {
setConnectionInProgress(false);
});
}}
@@ -1,7 +1,7 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { HTTP } from "@components/PageComponents/Connect/HTTP.tsx";
import { MeshDevice } from "@meshtastic/core";
import { TransportHTTP } from "@meshtastic/transport-http";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
vi.mock("@core/stores/appStore.ts", () => ({
@@ -35,8 +35,9 @@ describe("HTTP Component", () => {
render(<HTTP closeDialog={vi.fn()} />);
expect(screen.getByText("IP Address/Hostname")).toBeInTheDocument();
expect(screen.getByRole("textbox")).toBeInTheDocument();
expect(screen.getByPlaceholderText("000.000.000.000 / meshtastic.local"))
.toBeInTheDocument();
expect(
screen.getByPlaceholderText("000.000.000.000 / meshtastic.local"),
).toBeInTheDocument();
expect(screen.getByText("Use HTTPS")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Connect" })).toBeInTheDocument();
});
@@ -45,8 +46,9 @@ describe("HTTP Component", () => {
render(<HTTP closeDialog={vi.fn()} />);
const inputField = screen.getByRole("textbox");
fireEvent.change(inputField, { target: { value: "meshtastic.local" } });
expect(screen.getByPlaceholderText("000.000.000.000 / meshtastic.local"))
.toBeInTheDocument();
expect(
screen.getByPlaceholderText("000.000.000.000 / meshtastic.local"),
).toBeInTheDocument();
});
it("toggles HTTPS switch and updates prefix", () => {
@@ -1,19 +1,19 @@
import type { TabElementProps } from "@components/Dialog/NewDeviceDialog.tsx";
import { Button } from "@components/UI/Button.tsx";
import { Input } from "@components/UI/Input.tsx";
import { Link } from "@components/UI/Typography/Link.tsx";
import { Label } from "@components/UI/Label.tsx";
import { Switch } from "@components/UI/Switch.tsx";
import { Link } from "@components/UI/Typography/Link.tsx";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDeviceStore } from "@core/stores/deviceStore.ts";
import { useMessageStore } from "@core/stores/messageStore/index.ts";
import { subscribeAll } from "@core/subscriptions.ts";
import { randId } from "@core/utils/randId.ts";
import { MeshDevice } from "@meshtastic/core";
import { TransportHTTP } from "@meshtastic/transport-http";
import { AlertTriangle } from "lucide-react";
import { useState } from "react";
import { useController, useForm } from "react-hook-form";
import { AlertTriangle } from "lucide-react";
import { useMessageStore } from "@core/stores/messageStore/index.ts";
import { useTranslation } from "react-i18next";
interface FormData {
@@ -21,9 +21,7 @@ interface FormData {
tls: boolean;
}
export const HTTP = (
{ closeDialog }: TabElementProps,
) => {
export const HTTP = ({ closeDialog }: TabElementProps) => {
const { t } = useTranslation("dialog");
const [connectionInProgress, setConnectionInProgress] = useState(false);
const isURLHTTPS = location.protocol === "https:";
@@ -39,7 +37,7 @@ export const HTTP = (
)
? "meshtastic.local"
: globalThis.location.host,
tls: isURLHTTPS ? true : false,
tls: !!isURLHTTPS,
},
});
@@ -47,9 +45,10 @@ export const HTTP = (
field: { value: tlsValue, onChange: setTLS },
} = useController({ name: "tls", control });
const [connectionError, setConnectionError] = useState<
{ host: string; secure: boolean } | null
>(null);
const [connectionError, setConnectionError] = useState<{
host: string;
secure: boolean;
} | null>(null);
const onSubmit = handleSubmit(async (data) => {
setConnectionInProgress(true);
@@ -85,9 +84,11 @@ export const HTTP = (
<div>
<Label>{t("newDeviceDialog.httpConnection.label")}</Label>
<Input
prefix={tlsValue
prefix={
tlsValue
? `${t("newDeviceDialog.https")}://`
: `${t("newDeviceDialog.http")}://`}
: `${t("newDeviceDialog.http")}://`
}
placeholder={t("newDeviceDialog.httpConnection.placeholder")}
className="text-slate-900 dark:text-slate-100"
{...register("ip")}
@@ -138,7 +139,8 @@ export const HTTP = (
? t(
"newDeviceDialog.connectionFailedAlert.acceptTlsWarningSuffix",
)
: ""}.{" "}
: ""}
.{" "}
<Link
href="https://meshtastic.org/docs/software/web-client/#http"
className="underline font-medium text-amber-800 dark:text-amber-800"
@@ -151,10 +153,7 @@ export const HTTP = (
</div>
)}
</fieldset>
<Button
type="submit"
variant="default"
>
<Button type="submit" variant="default">
<span>
{connectionInProgress
? t("newDeviceDialog.connecting")
@@ -1,19 +1,17 @@
import type { TabElementProps } from "../../Dialog/NewDeviceDialog.tsx";
import { Button } from "@components/UI/Button.tsx";
import { Mono } from "@components/generic/Mono.tsx";
import { Button } from "@components/UI/Button.tsx";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDeviceStore } from "@core/stores/deviceStore.ts";
import { useMessageStore } from "@core/stores/messageStore/index.ts";
import { subscribeAll } from "@core/subscriptions.ts";
import { randId } from "@core/utils/randId.ts";
import { MeshDevice } from "@meshtastic/core";
import { TransportWebSerial } from "@meshtastic/transport-web-serial";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMessageStore } from "@core/stores/messageStore/index.ts";
import type { TabElementProps } from "../../Dialog/NewDeviceDialog.tsx";
export const Serial = (
{ closeDialog }: TabElementProps,
) => {
export const Serial = ({ closeDialog }: TabElementProps) => {
const [connectionInProgress, setConnectionInProgress] = useState(false);
const [serialPorts, setSerialPorts] = useState<SerialPort[]>([]);
const { addDevice } = useDeviceStore();
@@ -54,13 +52,13 @@ export const Serial = (
disabled={connectionInProgress}
>
<div className="flex h-48 flex-col gap-2 overflow-y-auto">
{serialPorts.map((port, index) => {
{serialPorts.map((port) => {
const { usbProductId, usbVendorId } = port.getInfo();
const vendor = usbVendorId ?? t("unknown.shortName");
const product = usbProductId ?? t("unknown.shortName");
return (
<Button
key={`${vendor}-${product}-${index}`}
key={`${vendor}-${product}`}
disabled={port.readable !== null}
variant="default"
onClick={async () => {
@@ -70,7 +68,6 @@ export const Serial = (
}}
>
{t("newDeviceDialog.serialConnection.deviceIdentifier", {
index: index,
vendorId: vendor,
productId: product,
})}
@@ -86,12 +83,16 @@ export const Serial = (
<Button
variant="default"
onClick={async () => {
await navigator.serial.requestPort().then((port) => {
await navigator.serial
.requestPort()
.then((port) => {
setSerialPorts(serialPorts.concat(port));
// No need to setConnectionInProgress(false) here if requestPort is quick
}).catch((error: Error) => {
})
.catch((error: Error) => {
console.error("Error requesting port:", error);
}).finally(() => {
})
.finally(() => {
setConnectionInProgress(false);
});
}}
@@ -1,12 +1,21 @@
import BatteryStatus from "@components/BatteryStatus.tsx";
import { Mono } from "@components/generic/Mono.tsx";
import { TimeAgo } from "@components/generic/TimeAgo.tsx";
import { Avatar } from "@components/UI/Avatar.tsx";
import { Separator } from "@components/UI/Seperator.tsx";
import { Heading } from "@components/UI/Typography/Heading.tsx";
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
import { formatQuantity } from "@core/utils/string.ts";
import { Avatar } from "@components/UI/Avatar.tsx";
import { Mono } from "@components/generic/Mono.tsx";
import { TimeAgo } from "@components/generic/TimeAgo.tsx";
import { Protobuf } from "@meshtastic/core";
import type { Protobuf as ProtobufType } from "@meshtastic/core";
import { Protobuf } from "@meshtastic/core";
import {
Tooltip,
TooltipContent,
TooltipPortal,
TooltipProvider,
TooltipTrigger,
} from "@radix-ui/react-tooltip";
import { useNavigate } from "@tanstack/react-router";
import {
Dot,
LockIcon,
@@ -15,16 +24,7 @@ import {
MountainSnow,
Star,
} from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipPortal,
TooltipProvider,
TooltipTrigger,
} from "@radix-ui/react-tooltip";
import BatteryStatus from "@components/BatteryStatus.tsx";
import { useTranslation } from "react-i18next";
import { useNavigate } from "@tanstack/react-router";
export interface NodeDetailProps {
node: ProtobufType.Mesh.NodeInfo;
@@ -60,16 +60,14 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
e.stopPropagation();
}}
>
{node.user?.publicKey && node.user?.publicKey.length > 0
? (
{node.user?.publicKey && node.user?.publicKey.length > 0 ? (
<LockIcon
className="text-green-600 mb-1.5"
size={12}
strokeWidth={3}
aria-label={t("node_detail_public_key_enabled_aria_label")}
/>
)
: (
) : (
<LockOpenIcon
className="text-yellow-500 mb-1.5"
size={12}
@@ -105,9 +103,11 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
<Star
fill={node.isFavorite ? "black" : "none"}
size={15}
aria-label={node.isFavorite
aria-label={
node.isFavorite
? t("nodeDetail.favorite.label")
: t("nodeDetail.notFavorite.label")}
: t("nodeDetail.notFavorite.label")
}
/>
</div>
</div>
@@ -1,6 +1,6 @@
import { MessageItem } from "@components/PageComponents/Messages/MessageItem.tsx";
import type { Message } from "@core/stores/messageStore/types.ts";
import { InboxIcon } from "lucide-react";
import { Message } from "@core/stores/messageStore/types.ts";
import { useTranslation } from "react-i18next";
export interface ChannelChatProps {
@@ -41,10 +41,7 @@ export const MessageActionsMenu = ({
const iconSizeClass = "size-4";
return (
<div
className={cn(hoverIconBarClass)}
onClick={(e) => e.stopPropagation()}
>
<div className={cn(hoverIconBarClass)}>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
@@ -1,3 +1,4 @@
import type { Types } from "@meshtastic/core";
import {
act,
fireEvent,
@@ -6,13 +7,10 @@ import {
waitFor,
} from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { MessageInput, MessageInputProps } from "./MessageInput.tsx";
import { Types } from "@meshtastic/core";
import { MessageInput, type MessageInputProps } from "./MessageInput.tsx";
vi.mock("@components/UI/Button.tsx", () => ({
Button: vi.fn((
{ type, className, children, onClick, onSubmit, ...rest },
) => (
Button: vi.fn(({ type, className, children, onClick, onSubmit, ...rest }) => (
<button
type={type}
className={className}
@@ -26,11 +24,8 @@ vi.mock("@components/UI/Button.tsx", () => ({
}));
vi.mock("@components/UI/Input.tsx", () => ({
Input: vi.fn((
{ autoFocus, minLength, name, placeholder, value, onChange },
) => (
Input: vi.fn(({ minLength, name, placeholder, value, onChange }) => (
<input
autoFocus={autoFocus}
minLength={minLength}
name={name}
placeholder={placeholder}
@@ -225,16 +220,14 @@ describe("MessageInput", () => {
it("should work with broadcast destination for drafts", () => {
const broadcastDest: Types.Destination = "broadcast";
mockGetDraft.mockImplementation((key) =>
key === broadcastDest ? "Broadcast draft" : ""
key === broadcastDest ? "Broadcast draft" : "",
);
renderComponent({ to: broadcastDest });
expect(mockGetDraft).toHaveBeenCalledWith(broadcastDest);
expect(
(screen.getByTestId(
"message-input-field",
) as HTMLInputElement).value,
(screen.getByTestId("message-input-field") as HTMLInputElement).value,
).toBe("Broadcast draft");
const inputElement = screen.getByTestId(
@@ -1,9 +1,9 @@
import { Button } from "@components/UI/Button.tsx";
import { Input } from "@components/UI/Input.tsx";
import { useMessageStore } from "@core/stores/messageStore/index.ts";
import type { Types } from "@meshtastic/core";
import { SendIcon } from "lucide-react";
import { startTransition, useState } from "react";
import { useMessageStore } from "@core/stores/messageStore/index.ts";
import { useTranslation } from "react-i18next";
export interface MessageInputProps {
@@ -12,11 +12,7 @@ export interface MessageInputProps {
maxBytes: number;
}
export const MessageInput = ({
onSend,
to,
maxBytes,
}: MessageInputProps) => {
export const MessageInput = ({ onSend, to, maxBytes }: MessageInputProps) => {
const { setDraft, getDraft, clearDraft } = useMessageStore();
const { t } = useTranslation("messages");
@@ -25,7 +21,7 @@ export const MessageInput = ({
const initialDraft = getDraft(to);
const [localDraft, setLocalDraft] = useState(initialDraft);
const [messageBytes, setMessageBytes] = useState(() =>
calculateBytes(initialDraft)
calculateBytes(initialDraft),
);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -41,7 +37,9 @@ export const MessageInput = ({
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!localDraft.trim()) return;
if (!localDraft.trim()) {
return;
}
// Reset bytes *before* sending (consider if onSend failure needs different handling)
setMessageBytes(0);
@@ -56,9 +54,8 @@ export const MessageInput = ({
<div className="flex gap-2">
<form className="w-full" name="messageInput" onSubmit={handleSubmit}>
<div className="flex grow gap-1">
<label className="w-full">
<label className="w-full" htmlFor="messageInput">
<Input
autoFocus
minLength={1}
name="messageInput"
placeholder={t("sendMessage.placeholder")}
@@ -70,15 +67,13 @@ export const MessageInput = ({
<label
data-testid="byte-counter"
htmlFor="messageInput"
className="flex items-center w-20 p-1 text-sm place-content-end"
>
{messageBytes}/{maxBytes}
</label>
<Button
type="submit"
variant="default"
>
<Button type="submit" variant="default">
<SendIcon size={16} />
</Button>
</div>
@@ -1,3 +1,4 @@
import { Avatar } from "@components/UI/Avatar.tsx";
import {
Tooltip,
TooltipArrow,
@@ -6,18 +7,18 @@ import {
TooltipTrigger,
} from "@components/UI/Tooltip.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { cn } from "@core/utils/cn.ts";
import { Avatar } from "@components/UI/Avatar.tsx";
import { AlertCircle, CheckCircle2, CircleEllipsis } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { ReactNode, useMemo } from "react";
import {
MessageState,
useMessageStore,
} from "@core/stores/messageStore/index.ts";
import { Protobuf, Types } from "@meshtastic/core";
import { Message } from "@core/stores/messageStore/types.ts";
import type { Message } from "@core/stores/messageStore/types.ts";
import { cn } from "@core/utils/cn.ts";
import { type Protobuf, Types } from "@meshtastic/core";
import type { LucideIcon } from "lucide-react";
import { AlertCircle, CheckCircle2, CircleEllipsis } from "lucide-react";
import { type ReactNode, useMemo } from "react";
import { useTranslation } from "react-i18next";
// import { MessageActionsMenu } from "@components/PageComponents/Messages/MessageActionsMenu.tsx"; // TODO: Uncomment when actions menu is implemented
interface MessageStatusInfo {
@@ -27,12 +28,13 @@ interface MessageStatusInfo {
iconClassName?: string;
}
const StatusTooltip = (
{ statusInfo, children }: {
const StatusTooltip = ({
statusInfo,
children,
}: {
statusInfo: MessageStatusInfo;
children: ReactNode;
},
) => (
}) => (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
@@ -77,15 +79,19 @@ export const MessageItem = ({ message }: MessageItemProps) => {
[t],
);
const UNKNOWN_STATUS = useMemo((): MessageStatusInfo => ({
const UNKNOWN_STATUS = useMemo(
(): MessageStatusInfo => ({
displayText: t("deliveryStatus.unknown.displayText"),
icon: AlertCircle,
ariaLabel: t("deliveryStatus.unknown.label"),
iconClassName: "text-red-500 dark:text-red-400",
}), [t]);
}),
[t],
);
const getMessageStatusInfo = useMemo(
() => (state: MessageState): MessageStatusInfo =>
() =>
(state: MessageState): MessageStatusInfo =>
MESSAGE_STATUS_MAP[state] ?? UNKNOWN_STATUS,
[MESSAGE_STATUS_MAP, UNKNOWN_STATUS],
);
@@ -103,8 +109,8 @@ export const MessageItem = ({ message }: MessageItemProps) => {
const longName = messageUser?.user?.longName;
const derivedShortName = messageUser?.user?.shortName || fallbackName;
const derivedDisplayName = longName || derivedShortName;
const isFavorite = messageUser?.num !== myNodeNum &&
messageUser?.isFavorite;
const isFavorite =
messageUser?.num !== myNodeNum && messageUser?.isFavorite;
return {
displayName: derivedDisplayName,
shortName: derivedShortName,
@@ -116,7 +122,7 @@ export const MessageItem = ({ message }: MessageItemProps) => {
const StatusIconComponent = messageStatusInfo.icon;
const messageDate = useMemo(
() => message.date ? new Date(message.date) : null,
() => (message.date ? new Date(message.date) : null),
[message.date],
);
const locale = i18n.language;
@@ -199,11 +205,9 @@ export const MessageItem = ({ message }: MessageItemProps) => {
</div>
</div>
{/* Actions Menu Placeholder */}
{
/* <div className="absolute top-1 right-1">
{/* <div className="absolute top-1 right-1">
<MessageActionsMenu onReply={() => console.log("Reply")} />
</div> */
}
</div> */}
</li>
);
};
@@ -1,9 +1,9 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { TraceRoute } from "@components/PageComponents/Messages/TraceRoute.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { mockDeviceStore } from "@core/stores/deviceStore.mock.ts";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core";
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@core/stores/deviceStore");
@@ -18,7 +18,7 @@ describe("TraceRoute", () => {
macaddr: new Uint8Array([0x01, 0x02, 0x03, 0x04]),
id: "source-node",
isLicensed: false,
role: Protobuf.Config.Config_DeviceConfig_Role["CLIENT"],
role: Protobuf.Config.Config_DeviceConfig_Role.CLIENT,
} as Protobuf.Mesh.NodeInfo["user"],
};
@@ -32,7 +32,7 @@ describe("TraceRoute", () => {
macaddr: new Uint8Array([0x05, 0x06, 0x07, 0x08]),
id: "destination-node",
isLicensed: false,
role: Protobuf.Config.Config_DeviceConfig_Role["CLIENT"],
role: Protobuf.Config.Config_DeviceConfig_Role.CLIENT,
} as Protobuf.Mesh.NodeInfo["user"],
};
@@ -123,13 +123,7 @@ describe("TraceRoute", () => {
});
it("renders '??' for missing SNR values", () => {
render(
<TraceRoute
from={fromUser}
to={toUser}
route={[1]}
/>,
);
render(<TraceRoute from={fromUser} to={toUser} route={[1]} />);
expect(screen.getByText("Node A")).toBeInTheDocument();
// Check for translated '??' placeholder
@@ -22,9 +22,7 @@ interface RoutePathProps {
snr?: number[];
}
const RoutePath = (
{ title, from, to, path, snr }: RoutePathProps,
) => {
const RoutePath = ({ title, from, to, path, snr }: RoutePathProps) => {
const { getNode } = useDevice();
const { t } = useTranslation();
@@ -1,4 +1,4 @@
import { useDevice } from "@core/stores/deviceStore.ts";
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import {
type AmbientLightingValidation,
AmbientLightingValidationSchema,
@@ -8,17 +8,19 @@ import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface AmbientLightingModuleConfigProps {
onFormInit: DynamicFormFormInit<AmbientLightingValidation>;
}
export const AmbientLighting = (
{ onFormInit }: AmbientLightingModuleConfigProps,
) => {
export const AmbientLighting = ({
onFormInit,
}: AmbientLightingModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "ambientLighting" });
const {
moduleConfig,
setWorkingModuleConfig,
@@ -1,3 +1,4 @@
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import {
type AudioValidation,
AudioValidationSchema,
@@ -8,15 +9,16 @@ import {
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface AudioModuleConfigProps {
onFormInit: DynamicFormFormInit<AudioValidation>;
}
export const Audio = ({ onFormInit }: AudioModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "audio" });
const {
moduleConfig,
setWorkingModuleConfig,
@@ -1,3 +1,4 @@
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import {
type CannedMessageValidation,
CannedMessageValidationSchema,
@@ -8,17 +9,19 @@ import {
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface CannedMessageModuleConfigProps {
onFormInit: DynamicFormFormInit<CannedMessageValidation>;
}
export const CannedMessage = (
{ onFormInit }: CannedMessageModuleConfigProps,
) => {
export const CannedMessage = ({
onFormInit,
}: CannedMessageModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "cannedMessage" });
const {
moduleConfig,
setWorkingModuleConfig,
@@ -92,7 +95,8 @@ export const CannedMessage = (
label: t("cannedMessage.inputbrokerEventCw.label"),
description: t("cannedMessage.inputbrokerEventCw.description"),
properties: {
enumValue: Protobuf.ModuleConfig
enumValue:
Protobuf.ModuleConfig
.ModuleConfig_CannedMessageConfig_InputEventChar,
},
},
@@ -102,7 +106,8 @@ export const CannedMessage = (
label: t("cannedMessage.inputbrokerEventCcw.label"),
description: t("cannedMessage.inputbrokerEventCcw.description"),
properties: {
enumValue: Protobuf.ModuleConfig
enumValue:
Protobuf.ModuleConfig
.ModuleConfig_CannedMessageConfig_InputEventChar,
},
},
@@ -112,7 +117,8 @@ export const CannedMessage = (
label: t("cannedMessage.inputbrokerEventPress.label"),
description: t("cannedMessage.inputbrokerEventPress.description"),
properties: {
enumValue: Protobuf.ModuleConfig
enumValue:
Protobuf.ModuleConfig
.ModuleConfig_CannedMessageConfig_InputEventChar,
},
},
@@ -1,4 +1,4 @@
import { useDevice } from "@core/stores/deviceStore.ts";
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import {
type DetectionSensorValidation,
DetectionSensorValidationSchema,
@@ -8,17 +8,20 @@ import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface DetectionSensorModuleConfigProps {
onFormInit: DynamicFormFormInit<DetectionSensorValidation>;
}
export const DetectionSensor = (
{ onFormInit }: DetectionSensorModuleConfigProps,
) => {
export const DetectionSensor = ({
onFormInit,
}: DetectionSensorModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "detectionSensor" });
const {
moduleConfig,
setWorkingModuleConfig,
@@ -135,7 +138,8 @@ export const DetectionSensor = (
},
],
properties: {
enumValue: Protobuf.ModuleConfig
enumValue:
Protobuf.ModuleConfig
.ModuleConfig_DetectionSensorConfig_TriggerType,
},
},
@@ -1,3 +1,4 @@
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import {
type ExternalNotificationValidation,
ExternalNotificationValidationSchema,
@@ -8,17 +9,19 @@ import {
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface ExternalNotificationModuleConfigProps {
onFormInit: DynamicFormFormInit<ExternalNotificationValidation>;
}
export const ExternalNotification = (
{ onFormInit }: ExternalNotificationModuleConfigProps,
) => {
export const ExternalNotification = ({
onFormInit,
}: ExternalNotificationModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "externalNotification" });
const {
moduleConfig,
setWorkingModuleConfig,

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