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

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