diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index e0d41bd6..c44eaa18 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -46,4 +46,4 @@ Check all that apply. If an item doesn't apply to your PR, you can leave it unch - [ ] Code follows project style guidelines - [ ] Documentation has been updated or added - [ ] Tests have been added or updated -- [ ] All i18n translation labels have bee added +- [ ] All i18n translation labels have been added/updated diff --git a/deno.json b/deno.json index 36466202..3306d537 100644 --- a/deno.json +++ b/deno.json @@ -7,6 +7,7 @@ "@layouts/": "./src/layouts/", "@std/path": "jsr:@std/path@^1.1.0" }, + "include": ["src", "./vite-env.d.ts"], "compilerOptions": { "lib": [ "DOM", @@ -24,8 +25,8 @@ "types": [ "vite/client", "node", - "@types/web-bluetooth", - "@types/w3c-web-serial" + "npm:@types/w3c-web-serial", + "npm:@types/web-bluetooth" ], "strictPropertyInitialization": false }, @@ -44,6 +45,14 @@ ], "report": "pretty" }, + "exclude": [ + "node_modules/", + "dist", + "build", + "coverage", + "out", + ".vscode-test" + ], "unstable": [ "sloppy-imports" ] diff --git a/deno.lock b/deno.lock index 0300c35a..b6ff8d96 100644 --- a/deno.lock +++ b/deno.lock @@ -5,6 +5,7 @@ "jsr:@std/path@^1.1.0": "1.1.0", "npm:@bufbuild/protobuf@^2.2.5": "2.2.5", "npm:@jsr/meshtastic__core@2.6.2": "2.6.2", + "npm:@jsr/meshtastic__core@2.6.4": "2.6.4", "npm:@jsr/meshtastic__js@2.6.0-0": "2.6.0-0", "npm:@jsr/meshtastic__transport-http@*": "0.2.1", "npm:@jsr/meshtastic__transport-web-bluetooth@*": "0.1.1", @@ -32,6 +33,7 @@ "npm:@tanstack/router-devtools@^1.120.15": "1.120.15_@tanstack+react-router@1.120.15__react@19.1.0__react-dom@19.1.0___react@19.1.0_react@19.1.0_react-dom@19.1.0__react@19.1.0", "npm:@tanstack/router-plugin@^1.120.15": "1.120.15_@tanstack+react-router@1.120.15__react@19.1.0__react-dom@19.1.0___react@19.1.0_vite@6.3.4__@types+node@22.15.3__picomatch@4.0.2_@babel+core@7.27.1_react@19.1.0_react-dom@19.1.0__react@19.1.0_@types+node@22.15.3", "npm:@testing-library/jest-dom@^6.6.3": "6.6.3", + "npm:@testing-library/react-hooks@^8.0.1": "8.0.1_@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0", "npm:@testing-library/react@^16.3.0": "16.3.0_@testing-library+dom@10.4.0_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0", "npm:@testing-library/user-event@^14.6.1": "14.6.1_@testing-library+dom@10.4.0", "npm:@turf/turf@^7.2.0": "7.2.0", @@ -41,12 +43,13 @@ "npm:@types/react-dom@^19.1.3": "19.1.3_@types+react@19.1.2", "npm:@types/react@^19.1.2": "19.1.2", "npm:@types/serviceworker@^0.0.133": "0.0.133", + "npm:@types/w3c-web-serial@*": "1.0.8", "npm:@types/w3c-web-serial@^1.0.8": "1.0.8", + "npm:@types/web-bluetooth@*": "0.0.21", "npm:@types/web-bluetooth@^0.0.21": "0.0.21", "npm:@vitejs/plugin-react@^4.4.1": "4.4.1_vite@6.3.4__@types+node@22.15.3__picomatch@4.0.2_@babel+core@7.27.1_@types+node@22.15.3", "npm:autoprefixer@^10.4.21": "10.4.21_postcss@8.5.3", "npm:base64-js@^1.5.1": "1.5.1", - "npm:class-validator@~0.14.2": "0.14.2", "npm:class-variance-authority@~0.7.1": "0.7.1", "npm:clsx@^2.1.1": "2.1.1", "npm:cmdk@^1.1.1": "1.1.1_react@19.1.0_react-dom@19.1.0__react@19.1.0_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2", @@ -83,7 +86,7 @@ "npm:vite-plugin-static-copy@3": "3.0.0_vite@6.3.4__@types+node@22.15.3__picomatch@4.0.2_@types+node@22.15.3", "npm:vite@^6.3.4": "6.3.4_@types+node@22.15.3_picomatch@4.0.2", "npm:vitest@^3.1.2": "3.1.2_@types+node@22.15.3_happy-dom@17.4.6_vite@6.3.4__@types+node@22.15.3__picomatch@4.0.2", - "npm:zod@^3.24.3": "3.24.3", + "npm:zod@^3.25.0": "3.25.49", "npm:zustand@5.0.4": "5.0.4_@types+react@19.1.2_immer@10.1.1_react@19.1.0" }, "jsr": { @@ -1131,6 +1134,17 @@ ], "tarball": "https://npm.jsr.io/~/11/@jsr/meshtastic__core/2.6.2.tgz" }, + "@jsr/meshtastic__core@2.6.4": { + "integrity": "sha512-1Kz5DK6peFxluHOJR38vFwfgeJzMXTz+3p6TvibjILVhSQC2U1nu8aJbn6w5zhRqS+j79OmtrRvdzL6VNsTkkQ==", + "dependencies": [ + "@bufbuild/protobuf", + "@jsr/meshtastic__protobufs", + "crc", + "ste-simple-events", + "tslog" + ], + "tarball": "https://npm.jsr.io/~/11/@jsr/meshtastic__core/2.6.4.tgz" + }, "@jsr/meshtastic__js@2.6.0-0": { "integrity": "sha512-+xpZpxK6oUIVOuEs7C+LyxRr2druvc7UNNNTK9Rl8ioXj63Jz1uQXlYe2Gj0xjnRAiSQLR7QVaPef21BR/YTxA==", "dependencies": [ @@ -1152,21 +1166,21 @@ "@jsr/meshtastic__transport-http@0.2.1": { "integrity": "sha512-lmQKr3aIINKvtGROU4HchmSVqbZSbkIHqajowRRC8IAjsnR0zNTyxz210QyY4pFUF9hpcW3GRjwq5h/VO2JuGg==", "dependencies": [ - "@jsr/meshtastic__core" + "@jsr/meshtastic__core@2.6.2" ], "tarball": "https://npm.jsr.io/~/11/@jsr/meshtastic__transport-http/0.2.1.tgz" }, "@jsr/meshtastic__transport-web-bluetooth@0.1.1": { "integrity": "sha512-eAj23n/Pxe8hMjO/uYbI/C+l1s0tLm41EzvcLWQtLQyEKJpPP+/Eqc5lUmDeF7FVPS2IYhllFJvV8Ili7okHtQ==", "dependencies": [ - "@jsr/meshtastic__core" + "@jsr/meshtastic__core@2.6.2" ], "tarball": "https://npm.jsr.io/~/11/@jsr/meshtastic__transport-web-bluetooth/0.1.1.tgz" }, "@jsr/meshtastic__transport-web-serial@0.2.1": { "integrity": "sha512-yumjEGLkAuJYOC3aWKvZzbQqi/LnqaKfNpVCY7Ki7oLtAshNiZrBLiwiFhN7+ZR9FfMdJThyBMqREBDRRWTO1Q==", "dependencies": [ - "@jsr/meshtastic__core" + "@jsr/meshtastic__core@2.6.2" ], "tarball": "https://npm.jsr.io/~/11/@jsr/meshtastic__transport-web-serial/0.2.1.tgz" }, @@ -2514,6 +2528,20 @@ "redent" ] }, + "@testing-library/react-hooks@8.0.1_@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0": { + "integrity": "sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g==", + "dependencies": [ + "@babel/runtime", + "@types/react", + "react", + "react-dom", + "react-error-boundary@3.1.4_react@19.1.0" + ], + "optionalPeers": [ + "@types/react", + "react-dom" + ] + }, "@testing-library/react@16.3.0_@testing-library+dom@10.4.0_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0": { "integrity": "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==", "dependencies": [ @@ -4000,9 +4028,6 @@ "@types/trusted-types@2.0.7": { "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==" }, - "@types/validator@13.15.0": { - "integrity": "sha512-nh7nrWhLr6CBq9ldtw0wx+z9wKnnv/uTVLA9g/3/TcOYxbpOSZE+MhKPmWqU+K0NvThjhv12uD8MuqijB0WzEA==" - }, "@types/w3c-web-serial@1.0.8": { "integrity": "sha512-QQOT+bxQJhRGXoZDZGLs3ksLud1dMNnMiSQtBA0w8KXvLpXX4oM4TZb6J0GgJ8UbCaHo5s9/4VQT8uXy9JER2A==" }, @@ -4485,14 +4510,6 @@ "safe-buffer@5.2.1" ] }, - "class-validator@0.14.2": { - "integrity": "sha512-3kMVRF2io8N8pY1IFIXlho9r8IPUUIfHe2hYVtiebvAzU2XeQFXTv+XI4WX+TnXmtwXMDcjngcpkiPM0O9PvLw==", - "dependencies": [ - "@types/validator", - "libphonenumber-js", - "validator" - ] - }, "class-variance-authority@0.7.1": { "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", "dependencies": [ @@ -5639,9 +5656,6 @@ "leven@3.1.0": { "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==" }, - "libphonenumber-js@1.12.7": { - "integrity": "sha512-0nYZSNj/QEikyhcM5RZFXGlCB/mr4PVamnT1C2sKBnDDTYndrvbybYjvg+PMqAndQHlLbwQ3socolnL3WWTUFA==" - }, "lightningcss-darwin-arm64@1.29.2": { "integrity": "sha512-cK/eMabSViKn/PG8U/a7aCorpeKLMlK0bQeNHmdb7qUnBkNPnL+oV5DjJUo0kqWsJUapZsM4jCfYItbqBDvlcA==", "os": ["darwin"], @@ -6212,6 +6226,13 @@ "scheduler" ] }, + "react-error-boundary@3.1.4_react@19.1.0": { + "integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==", + "dependencies": [ + "@babel/runtime", + "react" + ] + }, "react-error-boundary@6.0.0_react@19.1.0": { "integrity": "sha512-gdlJjD7NWr0IfkPlaREN2d9uUZUlksrfOx7SX62VRerwXbMY6ftGCIZua1VG1aXFNOimhISsTq+Owp725b9SiA==", "dependencies": [ @@ -7174,9 +7195,6 @@ "which-typed-array" ] }, - "validator@13.15.0": { - "integrity": "sha512-36B2ryl4+oL5QxZ3AzD0t5SsMNGvTtQHpjgFO5tbNxfXbMFkY822ktCDe1MnlqV3301QQI9SLHDNJokDI+Z9pA==" - }, "vite-node@3.1.2_@types+node@22.15.3": { "integrity": "sha512-/8iMryv46J3aK13iUXsei5G/A3CUlW4665THCPS+K8xAaqrVWiGB4RfXMQXCLjpK9P2eK//BczrVkn5JLAk6DA==", "dependencies": [ @@ -7553,8 +7571,8 @@ "yocto-queue@1.2.1": { "integrity": "sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==" }, - "zod@3.24.3": { - "integrity": "sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==" + "zod@3.25.49": { + "integrity": "sha512-JMMPMy9ZBk3XFEdbM3iL1brx4NUSejd6xr3ELrrGEfGb355gjhiAWtG3K5o+AViV/3ZfkIrCzXsZn6SbLwTR8Q==" }, "zone.js@0.8.29": { "integrity": "sha512-mla2acNCMkWXBD+c+yeUrBUrzOxYMNFdQ6FGfigGGtEVBPJx07BQeJekjt9DmH1FtZek4E9rE1eRR9qQpxACOQ==" @@ -7575,12 +7593,14 @@ }, "workspace": { "dependencies": [ - "jsr:@std/path@^1.1.0" + "jsr:@std/path@^1.1.0", + "npm:@types/w3c-web-serial@*", + "npm:@types/web-bluetooth@*" ], "packageJson": { "dependencies": [ "npm:@bufbuild/protobuf@^2.2.5", - "npm:@jsr/meshtastic__core@2.6.2", + "npm:@jsr/meshtastic__core@2.6.4", "npm:@jsr/meshtastic__js@2.6.0-0", "npm:@jsr/meshtastic__transport-http@*", "npm:@jsr/meshtastic__transport-web-bluetooth@*", @@ -7608,6 +7628,7 @@ "npm:@tanstack/router-devtools@^1.120.15", "npm:@tanstack/router-plugin@^1.120.15", "npm:@testing-library/jest-dom@^6.6.3", + "npm:@testing-library/react-hooks@^8.0.1", "npm:@testing-library/react@^16.3.0", "npm:@testing-library/user-event@^14.6.1", "npm:@turf/turf@^7.2.0", @@ -7622,7 +7643,6 @@ "npm:@vitejs/plugin-react@^4.4.1", "npm:autoprefixer@^10.4.21", "npm:base64-js@^1.5.1", - "npm:class-validator@~0.14.2", "npm:class-variance-authority@~0.7.1", "npm:clsx@^2.1.1", "npm:cmdk@^1.1.1", @@ -7659,7 +7679,7 @@ "npm:vite-plugin-static-copy@3", "npm:vite@^6.3.4", "npm:vitest@^3.1.2", - "npm:zod@^3.24.3", + "npm:zod@^3.25.0", "npm:zustand@5.0.4" ] } diff --git a/package.json b/package.json index 47ef278c..d6ed5138 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "homepage": "https://meshtastic.org", "dependencies": { "@bufbuild/protobuf": "^2.2.5", - "@meshtastic/core": "npm:@jsr/meshtastic__core@2.6.2", + "@meshtastic/core": "npm:@jsr/meshtastic__core@2.6.4", "@meshtastic/js": "npm:@jsr/meshtastic__js@2.6.0-0", "@meshtastic/transport-http": "npm:@jsr/meshtastic__transport-http", "@meshtastic/transport-web-bluetooth": "npm:@jsr/meshtastic__transport-web-bluetooth", @@ -61,6 +61,7 @@ "@tanstack/react-router-devtools": "^1.120.16", "@tanstack/router-devtools": "^1.120.15", "@turf/turf": "^7.2.0", + "@types/web-bluetooth": "^0.0.21", "base64-js": "^1.5.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -92,15 +93,15 @@ "@tanstack/router-plugin": "^1.120.15", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", + "@testing-library/react-hooks": "^8.0.1", "@testing-library/user-event": "^14.6.1", "@types/chrome": "^0.0.318", - "@types/js-cookie": "^3.0.6", "@types/node": "^22.15.3", "@types/react": "^19.1.2", "@types/react-dom": "^19.1.3", "@types/serviceworker": "^0.0.133", + "@types/js-cookie": "^3.0.6", "@types/w3c-web-serial": "^1.0.8", - "@types/web-bluetooth": "^0.0.21", "@vitejs/plugin-react": "^4.4.1", "autoprefixer": "^10.4.21", "gzipper": "^8.2.1", diff --git a/src/PageRouter.tsx b/src/PageRouter.tsx new file mode 100644 index 00000000..49b6fddf --- /dev/null +++ b/src/PageRouter.tsx @@ -0,0 +1,27 @@ +import MapPage from "@app/pages/Map/index.tsx"; +import { useDevice } from "@core/stores/deviceStore.ts"; +import ChannelsPage from "@pages/Channels.tsx"; +import ConfigPage from "@pages/Config/index.tsx"; +import MessagesPage from "@pages/Messages.tsx"; +import NodesPage from "./pages/Nodes/index.tsx"; +import { ErrorBoundary } from "react-error-boundary"; +import { ErrorPage } from "@components/UI/ErrorPage.tsx"; + +export const ErrorBoundaryWrapper = ({ + children, +}: { children: React.ReactNode }) => ( + {children} +); + +export const PageRouter = () => { + const { activePage } = useDevice(); + return ( + + {activePage === "messages" && } + {activePage === "map" && } + {activePage === "config" && } + {activePage === "channels" && } + {activePage === "nodes" && } + + ); +}; diff --git a/src/components/Dialog/DeviceNameDialog.tsx b/src/components/Dialog/DeviceNameDialog.tsx index 6b4d5803..b3dd94aa 100644 --- a/src/components/Dialog/DeviceNameDialog.tsx +++ b/src/components/Dialog/DeviceNameDialog.tsx @@ -55,10 +55,14 @@ export const DeviceNameDialog = ({ MAX_SHORT_NAME_BYTE_LENGTH, ); + if (!myNode?.user) { + console.warn("DeviceNameDialog: No user data available"); + return null; + } + const onSubmit = handleSubmit((data) => { connection?.setOwner( create(Protobuf.Mesh.UserSchema, { - ...(myNode?.user ?? {}), ...data, }), ); diff --git a/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.test.tsx b/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.test.tsx deleted file mode 100644 index 0ba79fd1..00000000 --- a/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.test.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; -import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx"; -import { useDevice } from "@core/stores/deviceStore.ts"; -import { useAppStore } from "@core/stores/appStore.ts"; -import type { Protobuf } from "@meshtastic/core"; - -vi.mock("@core/stores/deviceStore"); -vi.mock("@core/stores/appStore"); - -const mockUseDevice = vi.mocked(useDevice); -const mockUseAppStore = vi.mocked(useAppStore); - -vi.mock("@tanstack/react-router", () => ({ - useNavigate: vi.fn(), -})); - -describe("NodeDetailsDialog", () => { - const mockNode = { - num: 1234, - user: { - longName: "Test Node", - shortName: "TN", - hwModel: 1, - role: 1, - }, - lastHeard: 1697500000, - position: { - latitudeI: 450000000, - longitudeI: -750000000, - altitude: 200, - }, - deviceMetrics: { - airUtilTx: 50.123, - channelUtilization: 75.456, - batteryLevel: 88.789, - voltage: 4.2, - uptimeSeconds: 3600, - }, - } as unknown as Protobuf.Mesh.NodeInfo; - - beforeEach(() => { - vi.resetAllMocks(); - - mockUseDevice.mockReturnValue({ - getNode: (nodeNum: number) => { - if (nodeNum === 1234) { - return mockNode; - } - return undefined; - }, - }); - - mockUseAppStore.mockReturnValue({ - nodeNumDetails: 1234, - }); - }); - - it("renders node details correctly", () => { - render( {}} />); - - expect(screen.getByText(/Node Details for Test Node \(TN\)/i)) - .toBeInTheDocument(); - - expect(screen.getByText("Node Number: 1234")).toBeInTheDocument(); - expect(screen.getByText(/Node Hex: !/i)).toBeInTheDocument(); - expect(screen.getByText(/Last Heard:/i)).toBeInTheDocument(); - - expect(screen.getByText(/Coordinates:/i)).toBeInTheDocument(); - const link = screen.getByRole("link", { name: /^45, -75$/ }); - - expect(link).toBeInTheDocument(); - expect(link).toHaveAttribute( - "href", - expect.stringContaining("openstreetmap.org"), - ); - expect(screen.getByText(/Altitude: 200m/i)).toBeInTheDocument(); - - expect(screen.getByText(/Air TX utilization: 50.12%/i)).toBeInTheDocument(); - expect(screen.getByText(/Channel utilization: 75.46%/i)) - .toBeInTheDocument(); - expect(screen.getByText(/Battery level: 88.79%/i)).toBeInTheDocument(); - expect(screen.getByText(/Voltage: 4.20V/i)).toBeInTheDocument(); - expect(screen.getByText(/Uptime:/i)).toBeInTheDocument(); - - expect(screen.getByText(/All Raw Metrics:/i)).toBeInTheDocument(); - }); - - it("renders null if node is undefined", () => { - const requestedNodeNum = 5678; - - mockUseAppStore.mockReturnValue({ - nodeNumDetails: requestedNodeNum, - }); - - mockUseDevice.mockReturnValue({ - getNode: (nodeNum: number) => { - if (nodeNum === requestedNodeNum) { - return undefined; - } - if (nodeNum === 1234) { - return mockNode; - } - return undefined; - }, - }); - - const { container } = render( - {}} />, - ); - - expect(container.firstChild).toBeNull(); - expect(screen.queryByText(/Node Details for/i)).not.toBeInTheDocument(); - }); - - it("renders correctly when position is missing", () => { - const nodeWithoutPosition = { ...mockNode, position: undefined }; - mockUseDevice.mockReturnValue({ getNode: () => nodeWithoutPosition }); - mockUseAppStore.mockReturnValue({ nodeNumDetails: 1234 }); - - render( {}} />); - - expect(screen.queryByText(/Coordinates:/i)).not.toBeInTheDocument(); - expect(screen.queryByText(/Altitude:/i)).not.toBeInTheDocument(); - expect(screen.getByText(/Node Details for Test Node/i)).toBeInTheDocument(); - }); - - it("renders correctly when deviceMetrics are missing", () => { - const nodeWithoutMetrics = { ...mockNode, deviceMetrics: undefined }; - mockUseDevice.mockReturnValue({ getNode: () => nodeWithoutMetrics }); - mockUseAppStore.mockReturnValue({ nodeNumDetails: 1234 }); - - render( {}} />); - - expect(screen.queryByText(/Device Metrics:/i)).not.toBeInTheDocument(); - expect(screen.queryByText(/Air TX utilization:/i)).not.toBeInTheDocument(); - expect(screen.getByText(/Node Details for Test Node/i)).toBeInTheDocument(); - }); - - it("renders 'Never' for lastHeard when timestamp is 0", () => { - const nodeNeverHeard = { ...mockNode, lastHeard: 0 }; - mockUseDevice.mockReturnValue({ getNode: () => nodeNeverHeard }); - mockUseAppStore.mockReturnValue({ nodeNumDetails: 1234 }); - - render( {}} />); - - expect(screen.getByText(/Last Heard: Never/i)).toBeInTheDocument(); - }); -}); diff --git a/src/components/Dialog/QRDialog.tsx b/src/components/Dialog/QRDialog.tsx index ae3d259f..0cb914e0 100644 --- a/src/components/Dialog/QRDialog.tsx +++ b/src/components/Dialog/QRDialog.tsx @@ -13,7 +13,6 @@ import { Input } from "@components/UI/Input.tsx"; import { Label } from "@components/UI/Label.tsx"; import { Protobuf, type Types } from "@meshtastic/core"; import { fromByteArray } from "base64-js"; -import { ClipboardIcon } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { QRCode } from "react-qrcode-logo"; import { useTranslation } from "react-i18next"; @@ -92,7 +91,7 @@ export const QRDialog = ({ { + onChange={() => { if (selectedChannels.includes(channel.index)) { setSelectedChannels( selectedChannels.filter((c) => @@ -144,13 +143,6 @@ export const QRDialog = ({ diff --git a/src/components/Dialog/RebootDialog.tsx b/src/components/Dialog/RebootDialog.tsx index 9b59baa2..7ed48ce6 100644 --- a/src/components/Dialog/RebootDialog.tsx +++ b/src/components/Dialog/RebootDialog.tsx @@ -9,7 +9,7 @@ import { } from "@components/UI/Dialog.tsx"; import { Input } from "@components/UI/Input.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; -import { ClockIcon, RefreshCwIcon } from "lucide-react"; +import { RefreshCwIcon } from "lucide-react"; import { useTranslation } from "react-i18next"; import { useState } from "react"; @@ -45,12 +45,6 @@ export const RebootDialog = ({ className="dark:text-slate-900" value={time} onChange={(e) => setTime(Number.parseInt(e.target.value))} - action={{ - icon: ClockIcon, - onClick() { - connection?.reboot(time * 60).then(() => onOpenChange(false)); - }, - }} /> - - ); - }), -})); - -describe("Device component", () => { - const setWorkingConfigMock = vi.fn(); - const validateRoleSelectionMock = vi.fn(); - const mockDeviceConfig = { - role: "CLIENT", - buttonGpio: 0, - buzzerGpio: 0, - rebroadcastMode: "ALL", - nodeInfoBroadcastSecs: 300, - doubleTapAsButtonPress: false, - disableTripleClick: false, - ledHeartbeatDisabled: false, - }; - - beforeEach(() => { - vi.resetAllMocks(); - - // Mock the useDevice hook - useDevice.mockReturnValue({ - config: { - device: mockDeviceConfig, - }, - setWorkingConfig: setWorkingConfigMock, - }); - - // Mock the useUnsafeRolesDialog hook - validateRoleSelectionMock.mockResolvedValue(true); - useUnsafeRolesDialog.mockReturnValue({ - validateRoleSelection: validateRoleSelectionMock, - }); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - it("should render the Device form", () => { - render(); - expect(screen.getByTestId("dynamic-form")).toBeInTheDocument(); - }); - - it("should use the validateRoleSelection from the unsafe roles hook", () => { - render(); - expect(useUnsafeRolesDialog).toHaveBeenCalled(); - }); - - it("should call setWorkingConfig when form is submitted", async () => { - render(); - - fireEvent.click(screen.getByTestId("submit-button")); - - await waitFor(() => { - expect(setWorkingConfigMock).toHaveBeenCalledWith( - expect.objectContaining({ - payloadVariant: { - case: "device", - value: expect.objectContaining({ role: "CLIENT" }), - }, - }), - ); - }); - }); - - it("should create config with proper structure", async () => { - render(); - - // Simulate form submission - fireEvent.click(screen.getByTestId("submit-button")); - - await waitFor(() => { - expect(setWorkingConfigMock).toHaveBeenCalledWith( - expect.objectContaining({ - payloadVariant: { - case: "device", - value: expect.any(Object), - }, - }), - ); - }); - }); -}); diff --git a/src/components/PageComponents/Config/Network/Network.test.tsx b/src/components/PageComponents/Config/Network/Network.test.tsx deleted file mode 100644 index c15ec1ab..00000000 --- a/src/components/PageComponents/Config/Network/Network.test.tsx +++ /dev/null @@ -1,175 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { Network } from "@components/PageComponents/Config/Network/index.tsx"; -import { useDevice } from "@core/stores/deviceStore.ts"; -import { Protobuf } from "@meshtastic/core"; - -vi.mock("@core/stores/deviceStore", () => ({ - useDevice: vi.fn(), -})); - -vi.mock("@components/Form/DynamicForm", async () => { - const React = await import("react"); - const { useState } = React; - - return { - DynamicForm: ({ onSubmit, defaultValues }) => { - const [wifiEnabled, setWifiEnabled] = useState( - defaultValues.wifiEnabled ?? false, - ); - const [ssid, setSsid] = useState(defaultValues.wifiSsid ?? ""); - const [psk, setPsk] = useState(defaultValues.wifiPsk ?? ""); - - return ( -
{ - e.preventDefault(); - onSubmit({ - ...defaultValues, - wifiEnabled, - wifiSsid: ssid, - wifiPsk: psk, - }); - }} - data-testid="dynamic-form" - > - setWifiEnabled(e.target.checked)} - /> - setSsid(e.target.value)} - disabled={!wifiEnabled} - /> - setPsk(e.target.value)} - disabled={!wifiEnabled} - /> - -
- ); - }, - }; -}); - -describe("Network component", () => { - const setWorkingConfigMock = vi.fn(); - const mockNetworkConfig = { - wifiEnabled: false, - wifiSsid: "", - wifiPsk: "", - ntpServer: "", - ethEnabled: false, - addressMode: Protobuf.Config.Config_NetworkConfig_AddressMode.DHCP, - ipv4Config: { - ip: 0, - gateway: 0, - subnet: 0, - dns: 0, - }, - enabledProtocols: - Protobuf.Config.Config_NetworkConfig_ProtocolFlags.NO_BROADCAST, - rsyslogServer: "", - }; - - beforeEach(() => { - vi.resetAllMocks(); - - useDevice.mockReturnValue({ - config: { - network: mockNetworkConfig, - }, - setWorkingConfig: setWorkingConfigMock, - }); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - it("should render the Network form", () => { - render(); - expect(screen.getByTestId("dynamic-form")).toBeInTheDocument(); - }); - - it("should disable SSID and PSK fields when wifi is off", () => { - render(); - expect(screen.getByLabelText("SSID")).toBeDisabled(); - expect(screen.getByLabelText("PSK")).toBeDisabled(); - }); - - it("should enable SSID and PSK when wifi is toggled on", async () => { - render(); - const toggle = screen.getByLabelText("WiFi Enabled"); - screen.debug(); - - fireEvent.click(toggle); // turns wifiEnabled = true - - await waitFor(() => { - expect(screen.getByLabelText("SSID")).not.toBeDisabled(); - expect(screen.getByLabelText("PSK")).not.toBeDisabled(); - }); - }); - - it("should call setWorkingConfig with the right structure on submit", async () => { - render(); - - fireEvent.click(screen.getByTestId("submit-button")); - - await waitFor(() => { - expect(setWorkingConfigMock).toHaveBeenCalledWith( - expect.objectContaining({ - payloadVariant: { - case: "network", - value: expect.objectContaining({ - wifiEnabled: false, - wifiSsid: "", - wifiPsk: "", - ntpServer: "", - ethEnabled: false, - rsyslogServer: "", - }), - }, - }), - ); - }); - }); - - it("should submit valid data after enabling wifi and entering SSID and PSK", async () => { - render(); - fireEvent.click(screen.getByLabelText("WiFi Enabled")); - - fireEvent.change(screen.getByLabelText("SSID"), { - target: { value: "MySSID" }, - }); - - fireEvent.change(screen.getByLabelText("PSK"), { - target: { value: "MySecretPSK" }, - }); - - fireEvent.click(screen.getByTestId("submit-button")); - - await waitFor(() => { - expect(setWorkingConfigMock).toHaveBeenCalledWith( - expect.objectContaining({ - payloadVariant: { - case: "network", - value: expect.objectContaining({ - wifiEnabled: true, - wifiSsid: "MySSID", - wifiPsk: "MySecretPSK", - }), - }, - }), - ); - }); - }); -}); diff --git a/src/components/PageComponents/Connect/BLE.tsx b/src/components/PageComponents/Connect/BLE.tsx index 44aa508b..172f4497 100644 --- a/src/components/PageComponents/Connect/BLE.tsx +++ b/src/components/PageComponents/Connect/BLE.tsx @@ -6,8 +6,9 @@ import { useDeviceStore } from "@core/stores/deviceStore.ts"; import { subscribeAll } from "@core/subscriptions.ts"; import { randId } from "@core/utils/randId.ts"; import { BleConnection, ServiceUuid } from "@meshtastic/js"; +import { BluetoothDevice } from "web-bluetooth"; import { useCallback, useEffect, useState } from "react"; -import { useMessageStore } from "../../../core/stores/messageStore/index.ts"; +import { useMessageStore } from "@core/stores/messageStore/index.ts"; import { useTranslation } from "react-i18next"; export const BLE = ( @@ -78,7 +79,7 @@ export const BLE = ( if (exists === -1) { setBleDevices(bleDevices.concat(device)); } - }).catch((error) => { + }).catch((error: Error) => { console.error("Error requesting device:", error); setConnectionInProgress(false); }).finally(() => { diff --git a/src/components/PageComponents/Connect/HTTP.tsx b/src/components/PageComponents/Connect/HTTP.tsx index e1bca177..d92733af 100644 --- a/src/components/PageComponents/Connect/HTTP.tsx +++ b/src/components/PageComponents/Connect/HTTP.tsx @@ -66,7 +66,9 @@ export const HTTP = ( subscribeAll(device, connection, messageStore); closeDialog(); } catch (error) { - console.error("Connection error:", error); + if (error instanceof Error) { + console.error("Connection error:", error); + } // Capture all connection errors regardless of type setConnectionError({ host: data.ip, secure: data.tls }); setConnectionInProgress(false); diff --git a/src/components/PageComponents/Connect/Serial.tsx b/src/components/PageComponents/Connect/Serial.tsx index ff92c1e1..1303a016 100644 --- a/src/components/PageComponents/Connect/Serial.tsx +++ b/src/components/PageComponents/Connect/Serial.tsx @@ -9,7 +9,8 @@ import { MeshDevice } from "@meshtastic/core"; import { TransportWebSerial } from "@meshtastic/transport-web-serial"; import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { useMessageStore } from "../../../core/stores/messageStore/index.ts"; +import type { SerialPort } from "w3c-web-serial"; +import { useMessageStore } from "@core/stores/messageStore/index.ts"; export const Serial = ( { closeDialog }: TabElementProps, @@ -22,13 +23,13 @@ export const Serial = ( const { t } = useTranslation(); const updateSerialPortList = useCallback(async () => { - setSerialPorts(await navigator?.serial.getPorts()); + setSerialPorts(await navigator.serial.getPorts()); }, []); - navigator?.serial?.addEventListener("connect", () => { + navigator.serial.addEventListener("connect", () => { updateSerialPortList(); }); - navigator?.serial?.addEventListener("disconnect", () => { + navigator.serial.addEventListener("disconnect", () => { updateSerialPortList(); }); useEffect(() => { @@ -89,7 +90,7 @@ export const Serial = ( await navigator.serial.requestPort().then((port) => { setSerialPorts(serialPorts.concat(port)); // No need to setConnectionInProgress(false) here if requestPort is quick - }).catch((error) => { + }).catch((error: Error) => { console.error("Error requesting port:", error); }).finally(() => { setConnectionInProgress(false); diff --git a/src/components/PageComponents/Messages/MessageItem.tsx b/src/components/PageComponents/Messages/MessageItem.tsx index ea8dce86..ad6d7af1 100644 --- a/src/components/PageComponents/Messages/MessageItem.tsx +++ b/src/components/PageComponents/Messages/MessageItem.tsx @@ -56,21 +56,21 @@ export const MessageItem = ({ message }: MessageItemProps) => { const MESSAGE_STATUS_MAP = useMemo( (): Record => ({ [MessageState.Ack]: { - displayText: t("message_item_status_delivered_displayText"), + displayText: t("deliveryStatus.deliveryStatus."), icon: CheckCircle2, - ariaLabel: t("message_item_status_delivered_ariaLabel"), + ariaLabel: t("deliveryStatus.delivered"), iconClassName: "text-green-500", }, [MessageState.Waiting]: { - displayText: t("message_item_status_waiting_displayText"), + displayText: t("deliveryStatus.waiting"), icon: CircleEllipsis, - ariaLabel: t("message_item_status_waiting_ariaLabel"), + ariaLabel: t("deliveryStatus.waiting"), iconClassName: "text-slate-400", }, [MessageState.Failed]: { - displayText: t("message_item_status_failed_displayText"), + displayText: t("deliveryStatus.failed"), icon: AlertCircle, - ariaLabel: t("message_item_status_failed_ariaLabel"), + ariaLabel: t("deliveryStatus.failed"), iconClassName: "text-red-500 dark:text-red-400", }, }), @@ -78,9 +78,9 @@ export const MessageItem = ({ message }: MessageItemProps) => { ); const UNKNOWN_STATUS = useMemo((): MessageStatusInfo => ({ - displayText: t("message_item_status_unknown_displayText"), + displayText: t("delveryStatus.unknown"), icon: AlertCircle, - ariaLabel: t("message_item_status_unknown_ariaLabel"), + ariaLabel: t("deliveryStatus.unknown"), iconClassName: "text-red-500 dark:text-red-400", }), [t]); diff --git a/src/components/PageComponents/Messages/TraceRoute.test.tsx b/src/components/PageComponents/Messages/TraceRoute.test.tsx index cfd9dc6a..896f2567 100644 --- a/src/components/PageComponents/Messages/TraceRoute.test.tsx +++ b/src/components/PageComponents/Messages/TraceRoute.test.tsx @@ -2,23 +2,63 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { render, screen } from "@testing-library/react"; import { TraceRoute } from "@components/PageComponents/Messages/TraceRoute.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; -import type { Protobuf } from "@meshtastic/core"; +import { Protobuf } from "@meshtastic/core"; vi.mock("@core/stores/deviceStore"); describe("TraceRoute", () => { + const fromUser = { + user: { + $typeName: "meshtastic.User", + longName: "Source Node", + publicKey: new Uint8Array([1, 2, 3]), + shortName: "Source", + hwModel: 1, + macaddr: new Uint8Array([0x01, 0x02, 0x03, 0x04]), + id: "source-node", + isLicensed: false, + role: Protobuf.Config.Config_DeviceConfig_Role["CLIENT"], + } as Protobuf.Mesh.NodeInfo["user"], + }; + + const toUser = { + user: { + $typeName: "meshtastic.User", + longName: "Destination Node", + publicKey: new Uint8Array([4, 5, 6]), + shortName: "Destination", + hwModel: 2, + macaddr: new Uint8Array([0x05, 0x06, 0x07, 0x08]), + id: "destination-node", + isLicensed: false, + role: Protobuf.Config.Config_DeviceConfig_Role["CLIENT"], + } as Protobuf.Mesh.NodeInfo["user"], + }; + const mockNodes = new Map([ [ 1, - { num: 1, user: { longName: "Node A" } } as Protobuf.Mesh.NodeInfo, + { + num: 1, + user: { longName: "Node A", $typeName: "meshtastic.User" }, + $typeName: "meshtastic.NodeInfo", + } as Protobuf.Mesh.NodeInfo, ], [ 2, - { num: 2, user: { longName: "Node B" } } as Protobuf.Mesh.NodeInfo, + { + num: 2, + user: { longName: "Node B", $typeName: "meshtastic.User" }, + $typeName: "meshtastic.NodeInfo", + } as Protobuf.Mesh.NodeInfo, ], [ 3, - { num: 3, user: { longName: "Node C" } } as Protobuf.Mesh.NodeInfo, + { + num: 3, + user: { longName: "Node C", $typeName: "meshtastic.User" }, + $typeName: "meshtastic.NodeInfo", + } as Protobuf.Mesh.NodeInfo, ], ]); @@ -28,22 +68,21 @@ describe("TraceRoute", () => { getNode: (nodeNum: number): Protobuf.Mesh.NodeInfo | undefined => { return mockNodes.get(nodeNum); }, - }); + } as any); }); it("renders the route to destination with SNR values", () => { render( , ); - expect(screen.getAllByText("Source Node")).toHaveLength(1); + expect(screen.getByText("Source Node")).toBeInTheDocument(); expect(screen.getByText("Destination Node")).toBeInTheDocument(); - expect(screen.getByText("Node A")).toBeInTheDocument(); expect(screen.getByText("Node B")).toBeInTheDocument(); @@ -56,8 +95,8 @@ describe("TraceRoute", () => { it("renders the route back when provided", () => { render( { />, ); + // Check for the translated title expect(screen.getByText("Route back:")).toBeInTheDocument(); + // With route back, both names appear twice expect(screen.getAllByText("Source Node")).toHaveLength(2); - expect(screen.getAllByText("Destination Node")).toHaveLength(2); - expect(screen.getByText("Node C")).toBeInTheDocument(); expect(screen.getByText("Node A")).toBeInTheDocument(); - - expect(screen.getByText("↓ 35dBm")).toBeInTheDocument(); - expect(screen.getByText("↓ 45dBm")).toBeInTheDocument(); + expect(screen.getByText("Node C")).toBeInTheDocument(); expect(screen.getByText("↓ 15dBm")).toBeInTheDocument(); expect(screen.getByText("↓ 25dBm")).toBeInTheDocument(); + expect(screen.getByText("↓ 35dBm")).toBeInTheDocument(); + expect(screen.getByText("↓ 45dBm")).toBeInTheDocument(); }); it("renders '??' for missing SNR values", () => { render( , ); expect(screen.getByText("Node A")).toBeInTheDocument(); - expect(screen.getAllByText("↓ ??dBm")).toHaveLength(2); + // Check for translated '??' placeholder + expect(screen.getAllByText(/↓ \?\?dBm/)).toHaveLength(2); }); it("renders hop hex if node is not found", () => { render( , ); + // Check for translated '!' prefix + expect(screen.getByText("!63")).toBeInTheDocument(); expect(screen.getByText("↓ 5dBm")).toBeInTheDocument(); expect(screen.getByText("↓ 15dBm")).toBeInTheDocument(); }); diff --git a/src/components/PageComponents/Messages/TraceRoute.tsx b/src/components/PageComponents/Messages/TraceRoute.tsx index 6ad25952..a1d85aa6 100644 --- a/src/components/PageComponents/Messages/TraceRoute.tsx +++ b/src/components/PageComponents/Messages/TraceRoute.tsx @@ -3,9 +3,11 @@ import type { Protobuf } from "@meshtastic/core"; import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; import { useTranslation } from "react-i18next"; +type NodeUser = Pick; + export interface TraceRouteProps { - from?: Protobuf.Mesh.NodeInfo; - to?: Protobuf.Mesh.NodeInfo; + from: NodeUser; + to: NodeUser; route: Array; routeBack?: Array; snrTowards?: Array; @@ -14,14 +16,14 @@ export interface TraceRouteProps { interface RoutePathProps { title: string; - startNode?: Protobuf.Mesh.NodeInfo; - endNode?: Protobuf.Mesh.NodeInfo; + from: NodeUser; + to: NodeUser; path: number[]; snr?: number[]; } const RoutePath = ( - { title, startNode, endNode, path, snr }: RoutePathProps, + { title, from, to, path, snr }: RoutePathProps, ) => { const { getNode } = useDevice(); const { t } = useTranslation(); @@ -32,7 +34,7 @@ const RoutePath = ( className="ml-4 border-l-2 pl-2 border-l-slate-900 text-slate-900 dark:text-slate-100 dark:border-l-slate-100" >

{title}

-

{startNode?.user?.longName}

+

{from?.user?.longName}

↓ {snr?.[0] ?? t("unknown.num")} {t("unit.dbm")} @@ -49,7 +51,7 @@ const RoutePath = (

))} -

{endNode?.user?.longName}

+

{to?.user?.longName}

); }; @@ -67,16 +69,16 @@ export const TraceRoute = ({
{routeBack && routeBack.length > 0 && ( diff --git a/src/components/UI/Checkbox/Checkbox.test.tsx b/src/components/UI/Checkbox/Checkbox.test.tsx index 7e5ba73d..4e6bcb20 100644 --- a/src/components/UI/Checkbox/Checkbox.test.tsx +++ b/src/components/UI/Checkbox/Checkbox.test.tsx @@ -24,7 +24,7 @@ vi.mock("@components/UI/Label.tsx", () => ({ })); vi.mock("@core/utils/cn.ts", () => ({ - cn: (...args) => args.filter(Boolean).join(" "), + cn: (...args: any[]) => args.filter(Boolean).join(" "), })); vi.mock("react", async () => { diff --git a/src/components/generic/Filter/useFilterNode.ts b/src/components/generic/Filter/useFilterNode.ts index da3b7347..7aa962a5 100644 --- a/src/components/generic/Filter/useFilterNode.ts +++ b/src/components/generic/Filter/useFilterNode.ts @@ -1,153 +1,169 @@ import { Protobuf } from "@meshtastic/core"; import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; -import { useMemo } from "react"; +import { useCallback, useMemo } from "react"; export type FilterState = { nodeName: string; hopsAway: [number, number]; lastHeard: [number, number]; - isFavorite: boolean | undefined; // undefined -> don't filter - viaMqtt: boolean | undefined; // undefined -> don't filter + isFavorite: boolean | undefined; + viaMqtt: boolean | undefined; snr: [number, number]; channelUtilization: [number, number]; airUtilTx: [number, number]; batteryLevel: [number, number]; voltage: [number, number]; - role: (Protobuf.Config.Config_DeviceConfig_Role)[]; - hwModel: (Protobuf.Mesh.HardwareModel)[]; + role: Protobuf.Config.Config_DeviceConfig_Role[]; + hwModel: Protobuf.Mesh.HardwareModel[]; }; +const shallowEqualArray = (a: any[], b: any[]) => + a.length === b.length && a.every((v, i) => v === b[i]); + export function useFilterNode() { - const defaultFilterValues = useMemo(() => ({ - nodeName: "", - hopsAway: [0, 7], - lastHeard: [0, 864000], // 0-10 days - isFavorite: undefined, - viaMqtt: undefined, - snr: [-20, 10], - channelUtilization: [0, 100], - airUtilTx: [0, 100], - batteryLevel: [0, 101], - voltage: [0, 5], - role: Object.values(Protobuf.Config.Config_DeviceConfig_Role).filter( - (v): v is Protobuf.Config.Config_DeviceConfig_Role => - typeof v === "number", - ), - hwModel: Object.values(Protobuf.Mesh.HardwareModel).filter( - (v): v is Protobuf.Mesh.HardwareModel => typeof v === "number", - ), - }), []); - - function nodeFilter( - node: Protobuf.Mesh.NodeInfo, - filterOverrides?: Partial, - ): boolean { - const filterState: FilterState = { - ...defaultFilterValues, - ...filterOverrides, - }; - - if (!node.user) return false; - - const nodeName = filterState.nodeName.toLowerCase(); - if ( - !( - node.user?.shortName.toLowerCase().includes(nodeName) || - node.user?.longName.toLowerCase().includes(nodeName) || - node?.num.toString().includes(nodeName) || - numberToHexUnpadded(node?.num).includes( - nodeName.replace(/!/g, ""), + const defaultFilterValues = useMemo( + () => ({ + nodeName: "", + hopsAway: [0, 7], + lastHeard: [0, 864000], // 0-10 days + isFavorite: undefined, + viaMqtt: undefined, + snr: [-20, 10], + channelUtilization: [0, 100], + airUtilTx: [0, 100], + batteryLevel: [0, 101], + voltage: [0, 5], + role: Object.values(Protobuf.Config.Config_DeviceConfig_Role).filter( + (v): v is Protobuf.Config.Config_DeviceConfig_Role => + typeof v === "number", + ), + hwModel: Object.values(Protobuf.Mesh.HardwareModel).filter( + (v): v is Protobuf.Mesh.HardwareModel => typeof v === "number", + ), + }), + [], + ); + + const nodeFilter = useCallback( + ( + node: Protobuf.Mesh.NodeInfo, + filterOverrides?: Partial, + ): boolean => { + const filterState: FilterState = { + ...defaultFilterValues, + ...filterOverrides, + }; + + if (!node.user) return false; + + const nodeName = filterState.nodeName.toLowerCase(); + if ( + nodeName && + !( + node.user?.shortName.toLowerCase().includes(nodeName) || + node.user?.longName.toLowerCase().includes(nodeName) || + node.num.toString().includes(nodeName) || + numberToHexUnpadded(node.num).includes(nodeName.replace(/!/g, "")) ) - ) - ) return false; - - const hops = node?.hopsAway ?? 7; - if (hops < filterState.hopsAway[0] || hops > filterState.hopsAway[1]) { - return false; - } - - const secondsAgo = Date.now() / 1000 - (node?.lastHeard ?? 0); - if ( - secondsAgo < filterState.lastHeard[0] || - ( - secondsAgo > filterState.lastHeard[1] && - filterState.lastHeard[1] !== defaultFilterValues.lastHeard[1] - ) - ) return false; - - if ( - typeof filterState.isFavorite !== "undefined" && - node.isFavorite !== filterState.isFavorite - ) return false; - - if ( - typeof filterState.viaMqtt !== "undefined" && - node.viaMqtt !== filterState.viaMqtt - ) return false; - - const snr = node?.snr ?? -20; - if ( - snr < filterState.snr[0] || - snr > filterState.snr[1] - ) return false; - - const channelUtilization = node?.deviceMetrics?.channelUtilization ?? 0; - if ( - channelUtilization < filterState.channelUtilization[0] || - channelUtilization > filterState.channelUtilization[1] - ) return false; - - const airUtilTx = node?.deviceMetrics?.airUtilTx ?? 0; - if ( - airUtilTx < filterState.airUtilTx[0] || - airUtilTx > filterState.airUtilTx[1] - ) return false; - - const batt = node?.deviceMetrics?.batteryLevel ?? 101; - if ( - batt < filterState.batteryLevel[0] || - batt > filterState.batteryLevel[1] - ) return false; - - const voltage = node?.deviceMetrics?.voltage ?? 0; - if ( - voltage < filterState.voltage[0] || - voltage > filterState.voltage[1] - ) return false; - - const role: Protobuf.Config.Config_DeviceConfig_Role = node.user?.role ?? - Protobuf.Config.Config_DeviceConfig_Role.CLIENT; - if (!filterState.role.includes(role)) return false; - - const hwModel: Protobuf.Mesh.HardwareModel = node.user?.hwModel ?? - Protobuf.Mesh.HardwareModel.UNSET; - if (!filterState.hwModel.includes(hwModel)) return false; - - // All conditions are true - return true; - } - - // deno-lint-ignore no-explicit-any - function shallowEqualArray(a: any[], b: any[]) { - return a.length === b.length && a.every((v, i) => v === b[i]); - } - - function isFilterDirty( - current: FilterState, - overrides?: Partial, - ): boolean { - const base: FilterState = overrides - ? { ...defaultFilterValues, ...overrides } - : defaultFilterValues; - - return (Object.keys(base) as (keyof FilterState)[]).some((key) => { - const curr = current[key]; - const def = base[key]; - return Array.isArray(def) && Array.isArray(curr) - ? !shallowEqualArray(curr, def) - : curr !== def; - }); - } + ) { + return false; + } + + const hops = node.hopsAway ?? 7; + if (hops < filterState.hopsAway[0] || hops > filterState.hopsAway[1]) { + return false; + } + + const secondsAgo = Date.now() / 1000 - (node.lastHeard ?? 0); + if ( + secondsAgo < filterState.lastHeard[0] || + (secondsAgo > filterState.lastHeard[1] && + filterState.lastHeard[1] !== defaultFilterValues.lastHeard[1]) + ) { + return false; + } + + if ( + typeof filterState.isFavorite !== "undefined" && + node.isFavorite !== filterState.isFavorite + ) { + return false; + } + + if ( + typeof filterState.viaMqtt !== "undefined" && + node.viaMqtt !== filterState.viaMqtt + ) { + return false; + } + + const snr = node.snr ?? -20; + if (snr < filterState.snr[0] || snr > filterState.snr[1]) return false; + + const channelUtilization = node.deviceMetrics?.channelUtilization ?? 0; + if ( + channelUtilization < filterState.channelUtilization[0] || + channelUtilization > filterState.channelUtilization[1] + ) { + return false; + } + + const airUtilTx = node.deviceMetrics?.airUtilTx ?? 0; + if ( + airUtilTx < filterState.airUtilTx[0] || + airUtilTx > filterState.airUtilTx[1] + ) { + return false; + } + + const batt = node.deviceMetrics?.batteryLevel ?? 101; + if ( + batt < filterState.batteryLevel[0] || + batt > filterState.batteryLevel[1] + ) { + return false; + } + + const voltage = node.deviceMetrics?.voltage ?? 0; + if ( + voltage < filterState.voltage[0] || + voltage > filterState.voltage[1] + ) { + return false; + } + + const role: Protobuf.Config.Config_DeviceConfig_Role = node.user.role ?? + Protobuf.Config.Config_DeviceConfig_Role.CLIENT; + if (!filterState.role.includes(role)) return false; + + const hwModel: Protobuf.Mesh.HardwareModel = node.user.hwModel ?? + Protobuf.Mesh.HardwareModel.UNSET; + if (!filterState.hwModel.includes(hwModel)) return false; + + return true; + }, + [defaultFilterValues], + ); + + const isFilterDirty = useCallback( + ( + current: FilterState, + overrides?: Partial, + ): boolean => { + const base: FilterState = overrides + ? { ...defaultFilterValues, ...overrides } + : defaultFilterValues; + + return (Object.keys(base) as (keyof FilterState)[]).some((key) => { + const curr = current[key]; + const def = base[key]; + return Array.isArray(def) && Array.isArray(curr) + ? !shallowEqualArray(curr, def) + : curr !== def; + }); + }, + [defaultFilterValues], + ); return { nodeFilter, defaultFilterValues, isFilterDirty }; } diff --git a/src/components/generic/Table/index.tsx b/src/components/generic/Table/index.tsx index ae66a730..1ead83db 100755 --- a/src/components/generic/Table/index.tsx +++ b/src/components/generic/Table/index.tsx @@ -51,7 +51,7 @@ export const Table = ({ headings, rows }: TableProps) => { return null; } if (cell.type === React.Fragment) { - const childrenArray = React.Children.toArray(cell.props.children); + const childrenArray = React.Children.toArray((cell.props as any).children); const firstElement = childrenArray.find((child) => React.isValidElement(child) ); @@ -71,23 +71,23 @@ export const Table = ({ headings, rows }: TableProps) => { const elementB = getElement(b[columnIndex]); // Avatar contains the prop showFavorite which indicates isFavorite - const favA = a[0]?.props?.children?.props?.showFavorite ?? false; - const favB = b[0]?.props?.children?.props?.showFavorite ?? false; + const favA = (a[0] as any)?.props?.children?.props?.showFavorite ?? false; + const favB = (b[0] as any)?.props?.children?.props?.showFavorite ?? false; // Always put favorites at the top if (favA !== favB) return favA ? -1 : 1; if (sortColumn === "Last Heard") { - const aTimestamp = elementA?.props?.children?.props?.timestamp ?? 0; - const bTimestamp = elementB?.props?.children?.props?.timestamp ?? 0; + const aTimestamp = (elementA?.props as any)?.children?.props?.timestamp ?? 0; + const bTimestamp = (elementB?.props as any)?.children?.props?.timestamp ?? 0; if (aTimestamp < bTimestamp) return sortOrder === "asc" ? -1 : 1; if (aTimestamp > bTimestamp) return sortOrder === "asc" ? 1 : -1; return 0; } if (sortColumn === "Connection") { - const aHopsStr = elementA?.props?.children[0]; - const bHopsStr = elementB?.props?.children[0]; + const aHopsStr = (elementA?.props as any)?.children?.[0]; + const bHopsStr = (elementB?.props as any)?.children?.[0]; const aNumHops = numericHops(aHopsStr); const bNumHops = numericHops(bHopsStr); if (aNumHops < bNumHops) return sortOrder === "asc" ? -1 : 1; @@ -95,8 +95,8 @@ export const Table = ({ headings, rows }: TableProps) => { return 0; } - const aValue = elementA?.props?.children; - const bValue = elementB?.props?.children; + const aValue = (elementA?.props as any)?.children; + const bValue = (elementB?.props as any)?.children; const valA = aValue ?? ""; const valB = bValue ?? ""; diff --git a/src/core/dto/PacketToMessageDTO.ts b/src/core/dto/PacketToMessageDTO.ts index 6b50ebb4..3694c506 100644 --- a/src/core/dto/PacketToMessageDTO.ts +++ b/src/core/dto/PacketToMessageDTO.ts @@ -1,9 +1,6 @@ import type { Types } from "@meshtastic/js"; -import { - Message, - MessageState, - MessageType, -} from "../stores/messageStore/index.ts"; +import { MessageState, MessageType } from "../stores/messageStore/index.ts"; +import type { Message } from "../stores/messageStore/types.ts"; class PacketToMessageDTO { channel: Types.ChannelNumber; diff --git a/src/core/hooks/useBrowserFeatureDetection.ts b/src/core/hooks/useBrowserFeatureDetection.ts index 3f2667f2..e792ca1b 100644 --- a/src/core/hooks/useBrowserFeatureDetection.ts +++ b/src/core/hooks/useBrowserFeatureDetection.ts @@ -10,8 +10,8 @@ interface BrowserSupport { export function useBrowserFeatureDetection(): BrowserSupport { const support = useMemo(() => { const features: [BrowserFeature, boolean][] = [ - ["Web Bluetooth", !!navigator?.bluetooth], - ["Web Serial", !!navigator?.serial], + ["Web Bluetooth", !!navigator.bluetooth], + ["Web Serial", !!navigator.serial], [ "Secure Context", globalThis.location.protocol === "https:" || diff --git a/src/core/hooks/useCookie.ts b/src/core/hooks/useCookie.ts index df3d9d82..24e97b01 100644 --- a/src/core/hooks/useCookie.ts +++ b/src/core/hooks/useCookie.ts @@ -1,9 +1,9 @@ -import Cookies, { type CookieAttributes } from "js-cookie"; +import Cookies from "js-cookie"; import { useCallback, useState } from "react"; interface CookieHookResult { value: T | undefined; - setCookie: (value: T, options?: CookieAttributes) => void; + setCookie: (value: T, options?: Cookies.CookieAttributes) => void; removeCookie: () => void; } @@ -22,7 +22,7 @@ function useCookie( }); const setCookie = useCallback( - (value: T, options?: CookieAttributes) => { + (value: T, options?: Cookies.CookieAttributes) => { try { Cookies.set(cookieName, JSON.stringify(value), options); setCookieValue(value); diff --git a/src/core/hooks/usePinnedItems.test.ts b/src/core/hooks/usePinnedItems.test.ts index 3741ffdb..3b126666 100644 --- a/src/core/hooks/usePinnedItems.test.ts +++ b/src/core/hooks/usePinnedItems.test.ts @@ -6,7 +6,7 @@ const mockSetPinnedItems = vi.fn(); const mockUseLocalStorage = vi.fn(); vi.mock("@core/hooks/useLocalStorage.ts", () => ({ - default: (...args) => mockUseLocalStorage(...args), + default: (...args: any[]) => mockUseLocalStorage(...args), })); describe("usePinnedItems", () => { diff --git a/src/core/stores/deviceStore.ts b/src/core/stores/deviceStore.ts index d8b7b03d..488a5d5d 100644 --- a/src/core/stores/deviceStore.ts +++ b/src/core/stores/deviceStore.ts @@ -114,7 +114,7 @@ export const useDeviceStore = createStore((set, get) => ({ addDevice: (id: number) => { set( - produce((draft) => { + produce((draft) => { draft.devices.set(id, { id, status: Types.DeviceStatusEnum.DeviceDisconnected, @@ -151,7 +151,7 @@ export const useDeviceStore = createStore((set, get) => ({ setStatus: (status: Types.DeviceStatusEnum) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (device) { device.status = status; @@ -161,7 +161,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, setConfig: (config: Protobuf.Config.Config) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (device) { switch (config.payloadVariant.case) { @@ -203,7 +203,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, setModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (device) { switch (config.payloadVariant.case) { @@ -271,7 +271,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, setWorkingConfig: (config: Protobuf.Config.Config) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (!device) return; const index = device.workingConfig.findIndex( @@ -289,7 +289,7 @@ export const useDeviceStore = createStore((set, get) => ({ moduleConfig: Protobuf.ModuleConfig.ModuleConfig, ) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (!device) return; const index = device.workingModuleConfig.findIndex( @@ -307,7 +307,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, setHardware: (hardware: Protobuf.Mesh.MyNodeInfo) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (device) { device.hardware = hardware; @@ -317,7 +317,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, setPendingSettingsChanges: (state) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (device) { device.pendingSettingsChanges = state; @@ -327,7 +327,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, addChannel: (channel: Protobuf.Channel.Channel) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (device) { device.channels.set(channel.index, channel); @@ -337,7 +337,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, addWaypoint: (waypoint: Protobuf.Mesh.Waypoint) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (device) { const index = device.waypoints.findIndex((wp) => @@ -354,7 +354,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, addNodeInfo: (nodeInfo) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (!device) return; @@ -364,7 +364,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, setActiveNode: (node) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (device) { device.activeNode = node; @@ -374,7 +374,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, addUser: (user) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (!device) { return; @@ -389,7 +389,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, addPosition: (position) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (!device) { return; @@ -404,7 +404,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, addConnection: (connection) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (device) { device.connection = connection; @@ -414,7 +414,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, addMetadata: (from, metadata) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (device) { device.metadata.set(from, metadata); @@ -424,7 +424,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, addTraceRoute: (traceroute) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (!device) return; const routes = device.traceroutes.get(traceroute.from) ?? []; @@ -433,9 +433,9 @@ export const useDeviceStore = createStore((set, get) => ({ }), ); }, - removeNode: (nodeNum) => { + removeNode: (nodeNum: number) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (!device) { return; @@ -446,7 +446,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, setDialogOpen: (dialog: DialogVariant, open: boolean) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (device) { device.dialog[dialog] = open; @@ -461,7 +461,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, processPacket(data: ProcessPacketParams) { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (!device) return; const node = device.nodesMap.get(data.from); @@ -484,7 +484,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, setMessageDraft: (message: string) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (device) { device.messageDraft = message; @@ -492,9 +492,9 @@ export const useDeviceStore = createStore((set, get) => ({ }), ); }, - setNodeError: (nodeNum, error) => { + setNodeError: (nodeNum: number, error: string) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (device) { device.nodeErrors.set(nodeNum, { node: nodeNum, error }); @@ -504,7 +504,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, clearNodeError: (nodeNum: number) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (device) { device.nodeErrors.delete(nodeNum); @@ -524,7 +524,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, incrementUnread: (nodeNum: number) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (!device) return; const currentCount = device.unreadCounts.get(nodeNum) ?? 0; @@ -534,7 +534,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, resetUnread: (nodeNum: number) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (!device) return; device.unreadCounts.set(nodeNum, 0); @@ -610,10 +610,12 @@ export const useDeviceStore = createStore((set, get) => ({ })); set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); const node = device?.nodesMap.get(nodeNum); - node.isFavorite = isFavorite; + if (node) { + node.isFavorite = isFavorite; + } }), ); }, @@ -631,10 +633,12 @@ export const useDeviceStore = createStore((set, get) => ({ })); set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); const node = device?.nodesMap.get(nodeNum); - node.isIgnored = isIgnored; + if (node) { + node.isIgnored = isIgnored; + } }), ); }, @@ -651,7 +655,7 @@ export const useDeviceStore = createStore((set, get) => ({ removeDevice: (id) => { set( - produce((draft) => { + produce((draft) => { draft.devices.delete(id); }), ); diff --git a/src/core/stores/storage/indexDB.ts b/src/core/stores/storage/indexDB.ts index d7d89342..129fe9ae 100644 --- a/src/core/stores/storage/indexDB.ts +++ b/src/core/stores/storage/indexDB.ts @@ -1,4 +1,4 @@ -import { PersistStorage, StateStorage } from "zustand/middleware"; +import { PersistStorage, StateStorage, StorageValue } from "zustand/middleware"; import { del, get, set } from "idb-keyval"; import { ChannelId, MessageLogMap } from "@core/stores/messageStore/types.ts"; @@ -56,18 +56,18 @@ const reviver: JsonReviver = (_, value) => { }; export const storageWithMapSupport: PersistStorage = { - getItem: async (name): Promise => { + getItem: async (name): Promise | null> => { const str = await zustandIndexDBStorage.getItem(name); if (!str) return null; try { - const parsed = JSON.parse(str, reviver) as PersistedMessageState; + const parsed = JSON.parse(str, reviver) as StorageValue; return parsed; } catch (error) { console.error(`Error parsing persisted state (${name}):`, error); return null; } }, - setItem: async (name, newValue: PersistedMessageState): Promise => { + setItem: async (name, newValue: StorageValue): Promise => { try { const str = JSON.stringify(newValue, replacer); await zustandIndexDBStorage.setItem(name, str); diff --git a/src/core/utils/eventBus.test.ts b/src/core/utils/eventBus.test.ts index 6d2fc6ee..2d874066 100644 --- a/src/core/utils/eventBus.test.ts +++ b/src/core/utils/eventBus.test.ts @@ -4,7 +4,7 @@ import { eventBus } from "@core/utils/eventBus.ts"; describe("EventBus", () => { beforeEach(() => { // Reset event listeners before each test - eventBus.listeners = {}; + eventBus.offAll(); }); it("should register an event listener and trigger it on emit", () => { diff --git a/src/core/utils/eventBus.ts b/src/core/utils/eventBus.ts index 618ab5a6..75c48d8c 100644 --- a/src/core/utils/eventBus.ts +++ b/src/core/utils/eventBus.ts @@ -34,6 +34,14 @@ class EventBus { } } + public offAll(event?: T): void { + if (event) { + this.listeners[event] = []; + } else { + this.listeners = {}; + } + } + public emit(event: T, data: EventMap[T]): void { if (!this.listeners[event]) return; diff --git a/src/core/utils/ip.test.ts b/src/core/utils/ip.test.ts index 2da62e57..6208e6d3 100644 --- a/src/core/utils/ip.test.ts +++ b/src/core/utils/ip.test.ts @@ -60,7 +60,7 @@ describe("IP Address Conversion Functions", () => { for (const ip of testIps) { const int = convertIpAddressToInt(ip); expect(int).not.toBeNull(); - if (int !== null) { + if (int !== null && typeof int === "number") { const convertedBack = convertIntToIpAddress(int); expect(convertedBack).toBe(ip); } diff --git a/src/core/utils/sort.ts b/src/core/utils/sort.ts new file mode 100644 index 00000000..2d1494f0 --- /dev/null +++ b/src/core/utils/sort.ts @@ -0,0 +1,18 @@ +export function intlSort( + arr: T[], + order: "asc" | "desc" = "asc", + locale: Intl.Locale, +): T[] { + const collator = new Intl.Collator(locale, { sensitivity: "base" }); + + return arr.sort((a, b) => { + const stringA = String(a); + const stringB = String(b); + + if (order === "asc") { + return collator.compare(stringA, stringB); + } else { + return collator.compare(stringB, stringA); + } + }); +} diff --git a/src/i18n/config.ts b/src/i18n/config.ts index 4a43392f..59f58c7f 100644 --- a/src/i18n/config.ts +++ b/src/i18n/config.ts @@ -35,7 +35,7 @@ i18next "default": ["en"], }, fallbackNS: ["common", "ui", "dialog"], - debug: import.meta.env.DEV, + debug: import.meta.env.MODE === 'development', supportedLngs: supportedLanguages?.map((lang) => lang.code), ns: [ "channels", diff --git a/src/i18n/locales/en/commandPalette.json b/src/i18n/locales/en/commandPalette.json index 7b82e97b..1eed8987 100644 --- a/src/i18n/locales/en/commandPalette.json +++ b/src/i18n/locales/en/commandPalette.json @@ -1,7 +1,7 @@ { "emptyState": "No results found.", "page": { - "title": "Command Palette" + "title": "Command Menu" }, "pinGroup": { "label": "Pin command group" diff --git a/src/i18n/locales/en/ui.json b/src/i18n/locales/en/ui.json index 6f0b6aa3..7e2e4708 100644 --- a/src/i18n/locales/en/ui.json +++ b/src/i18n/locales/en/ui.json @@ -80,6 +80,12 @@ }, "showPassword": { "label": "Show password" + }, + "deliveryStatus": { + "delivered": "Delivered", + "failed": "Delivery Failed", + "waiting": "Waiting", + "unknown": "Unknown" } }, "general": { diff --git a/src/index.css b/src/index.css index 1ae593b4..41165535 100644 --- a/src/index.css +++ b/src/index.css @@ -3,6 +3,10 @@ @custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *)); +@view-transition { + navigation: auto; +} + @theme { --font-mono: Cascadia Code, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, @@ -89,6 +93,7 @@ body { } @layer base { + *, ::after, ::before, @@ -134,4 +139,4 @@ img { .animate-spin-slow { animation: spin-slower 2s linear infinite; -} +} \ No newline at end of file diff --git a/src/pages/Dashboard/index.tsx b/src/pages/Dashboard/index.tsx index 69cfa2e2..6afc4546 100644 --- a/src/pages/Dashboard/index.tsx +++ b/src/pages/Dashboard/index.tsx @@ -4,14 +4,7 @@ import { useDeviceStore } from "@core/stores/deviceStore.ts"; import { Button } from "@components/UI/Button.tsx"; import { Separator } from "@components/UI/Seperator.tsx"; import { Subtle } from "@components/UI/Typography/Subtle.tsx"; -import { - BluetoothIcon, - ListPlusIcon, - NetworkIcon, - PlusIcon, - UsbIcon, - UsersIcon, -} from "lucide-react"; +import { ListPlusIcon, PlusIcon, UsersIcon } from "lucide-react"; import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import LanguageSwitcher from "@components/LanguageSwitcher.tsx"; @@ -60,32 +53,6 @@ export const Dashboard = () => { ?.longName ?? t("unknown.shortName")}

-
- {device.connection?.connType === "ble" && ( - <> - - {t( - "dashboard.connectionType_ble", - )} - - )} - {device.connection?.connType === "serial" && ( - <> - - {t( - "dashboard.connectionType_serial", - )} - - )} - {device.connection?.connType === "http" && ( - <> - - {t( - "dashboard.connectionType_network", - )} - - )} -
{ {t("dashboard.noDevicesTitle")} - {/* */} {t("dashboard.noDevicesDescription")} diff --git a/src/pages/Messages.test.tsx b/src/pages/Messages.test.tsx index 46fc0115..f587ba30 100644 --- a/src/pages/Messages.test.tsx +++ b/src/pages/Messages.test.tsx @@ -44,13 +44,13 @@ const mockUseDevice = { describe.skip("Messages Page", () => { beforeEach(() => { - vi.mocked(useDevice).mockReturnValue(mockUseDevice); + vi.mocked(useDevice).mockReturnValue(mockUseDevice as any); }); it("sorts unreads to the top", () => { render(); const buttonOrder = screen.getAllByRole("button").filter((b) => - b.textContent.includes("Test Node") + b.textContent?.includes("Test Node") ); expect(buttonOrder[0].textContent).toContain("TN2Test Node 210"); expect(buttonOrder[1].textContent).toContain("TN1Test Node 13"); @@ -62,7 +62,7 @@ describe.skip("Messages Page", () => { render(); const nodeButton = screen.getAllByRole("button").filter((b) => - b.textContent.includes("TN1Test Node 13") + b.textContent?.includes("TN1Test Node 13") )[0]; fireEvent.click(nodeButton); expect(mockUseDevice.resetUnread).toHaveBeenCalledWith(1111, 0); @@ -72,7 +72,7 @@ describe.skip("Messages Page", () => { render(); const nodeButton = screen.getAllByRole("button").filter((b) => - b.textContent.includes("TN1Test Node 1") + b.textContent?.includes("TN1Test Node 1") )[0]; fireEvent.click(nodeButton); expect(mockUseDevice.resetUnread).toHaveBeenCalledWith(1111, 0); diff --git a/src/pages/Nodes.tsx b/src/pages/Nodes.tsx deleted file mode 100644 index c349cde3..00000000 --- a/src/pages/Nodes.tsx +++ /dev/null @@ -1,255 +0,0 @@ -import { LocationResponseDialog } from "@app/components/Dialog/LocationResponseDialog.tsx"; -import { TracerouteResponseDialog } from "@app/components/Dialog/TracerouteResponseDialog.tsx"; -import { Sidebar } from "@components/Sidebar.tsx"; -import { Avatar } from "@components/UI/Avatar.tsx"; -import { Mono } from "@components/generic/Mono.tsx"; -import { Table } from "@components/generic/Table/index.tsx"; -import { TimeAgo } from "@components/generic/TimeAgo.tsx"; -import { useDevice } from "@core/stores/deviceStore.ts"; -import { useAppStore } from "@core/stores/appStore.ts"; -import { Protobuf, type Types } from "@meshtastic/core"; -import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; -import { LockIcon, LockOpenIcon } from "lucide-react"; -import { - type JSX, - useCallback, - useDeferredValue, - useEffect, - useMemo, - useState, -} from "react"; -import { base16 } from "rfc4648"; -import { Input } from "@components/UI/Input.tsx"; -import { PageLayout } from "@components/PageLayout.tsx"; -import { - type FilterState, - useFilterNode, -} from "@components/generic/Filter/useFilterNode.ts"; -import { FilterControl } from "@components/generic/Filter/FilterControl.tsx"; -import { useTranslation } from "react-i18next"; - -export interface DeleteNoteDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; -} - -const NodesPage = (): JSX.Element => { - const { t } = useTranslation("nodes"); - const { getNodes, hardware, connection, hasNodeError, setDialogOpen } = - useDevice(); - const { setNodeNumDetails } = useAppStore(); - const { nodeFilter, defaultFilterValues, isFilterDirty } = useFilterNode(); - - const [selectedTraceroute, setSelectedTraceroute] = useState< - Types.PacketMetadata | undefined - >(); - const [selectedLocation, setSelectedLocation] = useState< - Types.PacketMetadata | undefined - >(); - - const [filterState, setFilterState] = useState(() => - defaultFilterValues - ); - const deferredFilterState = useDeferredValue(filterState); - - const filteredNodes = useMemo( - () => getNodes((node) => nodeFilter(node, deferredFilterState)), - [deferredFilterState, getNodes, nodeFilter], - ); - - useEffect(() => { - if (!connection) return; - connection.events.onTraceRoutePacket.subscribe(handleTraceroute); - return () => { - connection.events.onTraceRoutePacket.unsubscribe(handleTraceroute); - }; - }, [connection]); - - const handleTraceroute = useCallback( - (traceroute: Types.PacketMetadata) => { - setSelectedTraceroute(traceroute); - }, - [], - ); - - useEffect(() => { - if (!connection) return; - connection.events.onPositionPacket.subscribe(handleLocation); - return () => { - connection.events.onPositionPacket.subscribe(handleLocation); - }; - }, [connection]); - - const handleLocation = useCallback( - (location: Types.PacketMetadata) => { - if (location.to.valueOf() !== hardware.myNodeNum) return; - setSelectedLocation(location); - }, - [hardware.myNodeNum], - ); - - function handleNodeInfoDialog(nodeNum: number): void { - setNodeNumDetails(nodeNum); - setDialogOpen("nodeDetails", true); - } - - return ( - <> - } - > -
-
- - setFilterState((prev) => ({ - ...prev, - nodeName: e.target.value, - }))} - /> -
-
- -
-
-
- [ -
- -
, -

handleNodeInfoDialog(node.num)} - onKeyUp={(evt) => { - evt.key === "Enter" && handleNodeInfoDialog(node.num); - }} - className="cursor-pointer underline ml-2 whitespace-break-spaces" - tabIndex={0} - role="button" - > - {node.user?.longName ?? numberToHexUnpadded(node.num)} -

, - - {node.hopsAway !== undefined - ? node?.viaMqtt === false && node.hopsAway === 0 - ? t("nodesTable.connectionStatus.direct") - : `${node.hopsAway?.toString()} ${ - node.hopsAway ?? 0 > 1 - ? t("unit.hop.plural") - : t("unit.hops_one") - } ${t("nodesTable.connectionStatus.away")}` - : t("nodesTable.connectionStatus.unknown")} - {node?.viaMqtt === true - ? t("nodesTable.connectionStatus.viaMqtt") - : ""} - , - - {node.lastHeard === 0 - ?

{t("nodesTable.lastHeardStatus.never")}

- : } -
, - - {node.user?.publicKey && node.user?.publicKey.length > 0 - ? - : } - , - - {node.snr} - {t("unit.dbm")}/ - {Math.min( - Math.max((node.snr + 10) * 5, 0), - 100, - )}%/{/* Percentage */} - {(node.snr + 10) * 5} - {t("unit.raw")} - , - - {Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]} - , - - {base16 - .stringify(node.user?.macaddr ?? []) - .match(/.{1,2}/g) - ?.join(":") ?? t("unknown.shortName")} - , - ])} - /> - setSelectedTraceroute(undefined)} - /> - setSelectedLocation(undefined)} - /> - - - - ); -}; - -export default NodesPage; diff --git a/src/pages/Nodes/index.tsx b/src/pages/Nodes/index.tsx new file mode 100644 index 00000000..f87a8645 --- /dev/null +++ b/src/pages/Nodes/index.tsx @@ -0,0 +1,89 @@ +// @/pages/NodesPage.tsx (abbreviated) +import { useVirtualizer } from "@tanstack/react-virtual"; +import { JSX, useCallback, useDeferredValue, useRef, useState } from "react"; +import { NodeRow } from "./NodeRow.tsx"; +import { + FilterState, + useFilterNode, +} from "@components/generic/Filter/useFilterNode.ts"; +import { PageLayout } from "@components/PageLayout.tsx"; +import { Sidebar } from "@components/Sidebar.tsx"; +import { useAppStore } from "@core/stores/appStore.ts"; +import { useDevice } from "@core/stores/deviceStore.ts"; +// ... other imports + +const NodesPage = (): JSX.Element => { + const { getNodes, hasNodeError, setDialogOpen } = useDevice(); + const { setNodeNumDetails } = useAppStore(); + const { nodeFilter, defaultFilterValues } = useFilterNode(); + + // No changes to filtering logic, `useDeferredValue` is still a great choice + const [filterState, setFilterState] = useState(() => + defaultFilterValues + ); + const deferredFilterState = useDeferredValue(filterState); + const filteredNodes = getNodes((node) => + nodeFilter(node, deferredFilterState) + ); + + const parentRef = useRef(null); + + const rowVirtualizer = useVirtualizer({ + count: filteredNodes.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 64, // Estimate row height in pixels + overscan: 5, // Render 5 extra items in each direction + }); + + const handleNodeInfoDialog = useCallback( + (nodeNum: number): void => { + setNodeNumDetails(nodeNum); + setDialogOpen("nodeDetails", true); + }, + [setNodeNumDetails, setDialogOpen], + ); + + return ( + <> + }> + {/* ... Filter controls ... */} + + {/* Your scrollable container with the ref */} +
+ {/* Adjust height as needed */} + {/* A container to set the total size of the virtual list */} +
+ {/* Map over the virtual items, not the full filteredNodes array */} + {rowVirtualizer.getVirtualItems().map((virtualItem) => { + const node = filteredNodes[virtualItem.index]; + return ( + + ); + })} +
+
+ {/* ... Dialogs ... */} +
+ + ); +}; + +export default NodesPage; diff --git a/vite-env.d.ts b/vite-env.d.ts new file mode 100644 index 00000000..484e03a4 --- /dev/null +++ b/vite-env.d.ts @@ -0,0 +1,11 @@ +/// + +interface ImportMetaEnv { + readonly env: { + readonly VITE_COMMIT_HASH: string; + }; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/vite.config.ts b/vite.config.ts index 05daf0e3..83bba01f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -32,9 +32,9 @@ export default defineConfig({ targets: [ { src: "src/i18n/locales/**/*", - dest: "src/i18n/locales" - } - ] + dest: "src/i18n/locales", + }, + ], }), ], define: {