diff --git a/package.json b/package.json index 1c055073..a1441b0d 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "license": "GPL-3.0-only", "scripts": { "build": "pnpm check && rsbuild build", + "build:analyze": "BUNDLE_ANALYZE=true rsbuild build", "check": "biome check src/", "check:fix": "pnpm check --write src/", "format": "biome format --write src/", @@ -17,6 +18,9 @@ "simple-git-hooks": { "pre-commit": "npm run check:fix && npm run format" }, + "lint-staged": { + "*.{ts,tsx}": ["npm run check:fix", "npm run format"] + }, "repository": { "type": "git", "url": "git+https://github.com/meshtastic/web.git" @@ -61,7 +65,6 @@ "react-map-gl": "7.1.9", "react-qrcode-logo": "^3.0.0", "rfc4648": "^1.5.4", - "timeago-react": "^3.0.6", "vite-plugin-node-polyfills": "^0.23.0", "zustand": "5.0.3" }, @@ -86,5 +89,5 @@ "tar": "^7.4.3", "typescript": "^5.7.3" }, - "packageManager": "pnpm@9.15.4" + "packageManager": "pnpm@10.1.0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f0d0f030..7a2029e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -113,9 +113,6 @@ importers: rfc4648: specifier: ^1.5.4 version: 1.5.4 - timeago-react: - specifier: ^3.0.6 - version: 3.0.6(react@19.0.0) vite-plugin-node-polyfills: specifier: ^0.23.0 version: 0.23.0(rollup@4.29.1)(vite@5.3.6(@types/node@22.12.0)) @@ -2817,14 +2814,6 @@ packages: through2@4.0.2: resolution: {integrity: sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==} - timeago-react@3.0.6: - resolution: {integrity: sha512-4ywnCX3iFjdp84WPK7gt8s4n0FxXbYM+xv8hYL73p83dpcMxzmO+0W4xJuxflnkWNvum5aEaqTe6LZ3lUIudjQ==} - peerDependencies: - react: ^0.14.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 - - timeago.js@4.0.2: - resolution: {integrity: sha512-a7wPxPdVlQL7lqvitHGGRsofhdwtkoSXPGATFuSOA2i1ZNQEPLrGnj68vOp2sOJTCFAQVXPeNMX/GctBaO9L2w==} - timers-browserify@2.0.12: resolution: {integrity: sha512-9phl76Cqm6FhSX9Xe1ZUAMLtm1BLkKj2Qd5ApyWkXzsMRaA7dgr81kf4wJmQf/hAvg8EEyJxDo3du/0KlhPiKQ==} engines: {node: '>=0.6.0'} @@ -6498,13 +6487,6 @@ snapshots: dependencies: readable-stream: 3.6.2 - timeago-react@3.0.6(react@19.0.0): - dependencies: - react: 19.0.0 - timeago.js: 4.0.2 - - timeago.js@4.0.2: {} - timers-browserify@2.0.12: dependencies: setimmediate: 1.0.5 diff --git a/public/devices/README.md b/public/devices/README.md new file mode 100644 index 00000000..0e40ce8d --- /dev/null +++ b/public/devices/README.md @@ -0,0 +1,5 @@ +# Copyright Notice +Copyright © 2024 Meshtastic LLC. All Rights Reserved. + +## In reference to the GNU GPLv3 License terms defined in Section 7e +Images (or assets) in this directory are protected under international copyright laws and treaties. Unauthorized reproduction, distribution, modification, or use of these images in any form, commercial or otherwise, outside of official Meshtastic creative works or its Backers and Partners is strictly prohibited without prior written consent from the copyright holder (Meshtastic LLC). diff --git a/public/devices/diy.svg b/public/devices/diy.svg new file mode 100644 index 00000000..823467ed --- /dev/null +++ b/public/devices/diy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/devices/heltec-ht62-esp32c3-sx1262.svg b/public/devices/heltec-ht62-esp32c3-sx1262.svg new file mode 100644 index 00000000..c52534ef --- /dev/null +++ b/public/devices/heltec-ht62-esp32c3-sx1262.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/devices/heltec-mesh-node-t114-case.svg b/public/devices/heltec-mesh-node-t114-case.svg new file mode 100644 index 00000000..b2abe639 --- /dev/null +++ b/public/devices/heltec-mesh-node-t114-case.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/devices/heltec-mesh-node-t114.svg b/public/devices/heltec-mesh-node-t114.svg new file mode 100644 index 00000000..779a8f6a --- /dev/null +++ b/public/devices/heltec-mesh-node-t114.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/devices/heltec-v3-case.svg b/public/devices/heltec-v3-case.svg new file mode 100644 index 00000000..1b1d3c55 --- /dev/null +++ b/public/devices/heltec-v3-case.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/devices/heltec-v3.svg b/public/devices/heltec-v3.svg new file mode 100644 index 00000000..13a5fa64 --- /dev/null +++ b/public/devices/heltec-v3.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/devices/heltec-vision-master-e213.svg b/public/devices/heltec-vision-master-e213.svg new file mode 100644 index 00000000..2c1cca09 --- /dev/null +++ b/public/devices/heltec-vision-master-e213.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/devices/heltec-vision-master-e290.svg b/public/devices/heltec-vision-master-e290.svg new file mode 100644 index 00000000..ca7d296a --- /dev/null +++ b/public/devices/heltec-vision-master-e290.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/devices/heltec-vision-master-t190.svg b/public/devices/heltec-vision-master-t190.svg new file mode 100644 index 00000000..55db34f9 --- /dev/null +++ b/public/devices/heltec-vision-master-t190.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/devices/heltec-wireless-paper-V1_0.svg b/public/devices/heltec-wireless-paper-V1_0.svg new file mode 100644 index 00000000..cb3f188d --- /dev/null +++ b/public/devices/heltec-wireless-paper-V1_0.svg @@ -0,0 +1 @@ + diff --git a/public/devices/heltec-wireless-paper.svg b/public/devices/heltec-wireless-paper.svg new file mode 100644 index 00000000..cb3f188d --- /dev/null +++ b/public/devices/heltec-wireless-paper.svg @@ -0,0 +1 @@ + diff --git a/public/devices/heltec-wireless-tracker-V1-0.svg b/public/devices/heltec-wireless-tracker-V1-0.svg new file mode 100644 index 00000000..a5392595 --- /dev/null +++ b/public/devices/heltec-wireless-tracker-V1-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/devices/heltec-wireless-tracker.svg b/public/devices/heltec-wireless-tracker.svg new file mode 100644 index 00000000..a5392595 --- /dev/null +++ b/public/devices/heltec-wireless-tracker.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/devices/heltec-wsl-v3.svg b/public/devices/heltec-wsl-v3.svg new file mode 100644 index 00000000..1741223e --- /dev/null +++ b/public/devices/heltec-wsl-v3.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/devices/nano-g2-ultra.svg b/public/devices/nano-g2-ultra.svg new file mode 100644 index 00000000..6dbe47af --- /dev/null +++ b/public/devices/nano-g2-ultra.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/devices/pico.svg b/public/devices/pico.svg new file mode 100644 index 00000000..82ce6526 --- /dev/null +++ b/public/devices/pico.svg @@ -0,0 +1,2956 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/devices/promicro.svg b/public/devices/promicro.svg new file mode 100644 index 00000000..3dc26021 --- /dev/null +++ b/public/devices/promicro.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/devices/rak-wismeshtap.svg b/public/devices/rak-wismeshtap.svg new file mode 100644 index 00000000..34e77876 --- /dev/null +++ b/public/devices/rak-wismeshtap.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/devices/rak11310.svg b/public/devices/rak11310.svg new file mode 100644 index 00000000..8f526a47 --- /dev/null +++ b/public/devices/rak11310.svg @@ -0,0 +1,2339 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/devices/rak2560.svg b/public/devices/rak2560.svg new file mode 100644 index 00000000..b8514f01 --- /dev/null +++ b/public/devices/rak2560.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/devices/rak4631.svg b/public/devices/rak4631.svg new file mode 100644 index 00000000..6dc2957a --- /dev/null +++ b/public/devices/rak4631.svg @@ -0,0 +1,3514 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/devices/rak4631_case.svg b/public/devices/rak4631_case.svg new file mode 100644 index 00000000..a0b2bbb8 --- /dev/null +++ b/public/devices/rak4631_case.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/devices/rpipicow.svg b/public/devices/rpipicow.svg new file mode 100644 index 00000000..cb4b1f68 --- /dev/null +++ b/public/devices/rpipicow.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/devices/seeed-sensecap-indicator.svg b/public/devices/seeed-sensecap-indicator.svg new file mode 100644 index 00000000..f7bf9db0 --- /dev/null +++ b/public/devices/seeed-sensecap-indicator.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/devices/seeed-xiao-s3.svg b/public/devices/seeed-xiao-s3.svg new file mode 100644 index 00000000..04e97fe0 --- /dev/null +++ b/public/devices/seeed-xiao-s3.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/devices/station-g2.svg b/public/devices/station-g2.svg new file mode 100644 index 00000000..8d2e0aed --- /dev/null +++ b/public/devices/station-g2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/devices/t-deck.svg b/public/devices/t-deck.svg new file mode 100644 index 00000000..cdc53c5d --- /dev/null +++ b/public/devices/t-deck.svg @@ -0,0 +1 @@ +QWERTYIUPOASDFGHKJLaltZXCVBMN \ No newline at end of file diff --git a/public/devices/t-echo.svg b/public/devices/t-echo.svg new file mode 100644 index 00000000..e178a50f --- /dev/null +++ b/public/devices/t-echo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/devices/t-watch-s3.svg b/public/devices/t-watch-s3.svg new file mode 100644 index 00000000..19084c19 --- /dev/null +++ b/public/devices/t-watch-s3.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/devices/tbeam-s3-core.svg b/public/devices/tbeam-s3-core.svg new file mode 100644 index 00000000..f42e6d2c --- /dev/null +++ b/public/devices/tbeam-s3-core.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/devices/tbeam.svg b/public/devices/tbeam.svg new file mode 100644 index 00000000..cd0475c6 --- /dev/null +++ b/public/devices/tbeam.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/devices/tlora-c6.svg b/public/devices/tlora-c6.svg new file mode 100644 index 00000000..8b626638 --- /dev/null +++ b/public/devices/tlora-c6.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/devices/tlora-t3s3-epaper.svg b/public/devices/tlora-t3s3-epaper.svg new file mode 100644 index 00000000..6f2e8452 --- /dev/null +++ b/public/devices/tlora-t3s3-epaper.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/devices/tlora-t3s3-v1.svg b/public/devices/tlora-t3s3-v1.svg new file mode 100644 index 00000000..1f8847d4 --- /dev/null +++ b/public/devices/tlora-t3s3-v1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/devices/tlora-v2-1-1_6.svg b/public/devices/tlora-v2-1-1_6.svg new file mode 100644 index 00000000..dbe36ef5 --- /dev/null +++ b/public/devices/tlora-v2-1-1_6.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/devices/tlora-v2-1-1_8.svg b/public/devices/tlora-v2-1-1_8.svg new file mode 100644 index 00000000..dbe36ef5 --- /dev/null +++ b/public/devices/tlora-v2-1-1_8.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/devices/tracker-t1000-e.svg b/public/devices/tracker-t1000-e.svg new file mode 100644 index 00000000..6f7a06c9 --- /dev/null +++ b/public/devices/tracker-t1000-e.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/devices/unknown.svg b/public/devices/unknown.svg new file mode 100644 index 00000000..1d2cd87b --- /dev/null +++ b/public/devices/unknown.svg @@ -0,0 +1,160 @@ + + diff --git a/public/devices/wio-tracker-wm1110.svg b/public/devices/wio-tracker-wm1110.svg new file mode 100644 index 00000000..15ace5c5 --- /dev/null +++ b/public/devices/wio-tracker-wm1110.svg @@ -0,0 +1 @@ +LoRaWI FILEDRESETGNSSBLE \ No newline at end of file diff --git a/public/devices/wm1110_dev_kit.svg b/public/devices/wm1110_dev_kit.svg new file mode 100644 index 00000000..94aefe30 --- /dev/null +++ b/public/devices/wm1110_dev_kit.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/Dialog/DialogManager.tsx b/src/components/Dialog/DialogManager.tsx index e1769751..d571ffcb 100644 --- a/src/components/Dialog/DialogManager.tsx +++ b/src/components/Dialog/DialogManager.tsx @@ -6,6 +6,8 @@ import { QRDialog } from "@components/Dialog/QRDialog.tsx"; import { RebootDialog } from "@components/Dialog/RebootDialog.tsx"; import { ShutdownDialog } from "@components/Dialog/ShutdownDialog.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; +import type { JSX } from "react"; +import { NodeDetailsDialog } from "./NodeDetailsDialog"; export const DialogManager = (): JSX.Element => { const { channels, config, dialog, setDialogOpen } = useDevice(); @@ -56,6 +58,12 @@ export const DialogManager = (): JSX.Element => { setDialogOpen("pkiBackup", open); }} /> + { + setDialogOpen("nodeDetails", open); + }} + /> ); }; diff --git a/src/components/Dialog/LocationResponseDialog.tsx b/src/components/Dialog/LocationResponseDialog.tsx new file mode 100644 index 00000000..825046e6 --- /dev/null +++ b/src/components/Dialog/LocationResponseDialog.tsx @@ -0,0 +1,62 @@ +import { useDevice } from "@app/core/stores/deviceStore"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@components/UI/Dialog"; +import type { Protobuf, Types } from "@meshtastic/js"; +import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; +import type { JSX } from "react"; + +export interface LocationResponseDialogProps { + location: Types.PacketMetadata | undefined; + open: boolean; + onOpenChange: () => void; +} + +export const LocationResponseDialog = ({ + location, + open, + onOpenChange, +}: LocationResponseDialogProps): JSX.Element => { + const { nodes } = useDevice(); + + const from = nodes.get(location?.from ?? 0); + const longName = + from?.user?.longName ?? + (from ? `!${numberToHexUnpadded(from?.num)}` : "Unknown"); + const shortName = + from?.user?.shortName ?? + (from ? `${numberToHexUnpadded(from?.num).substring(0, 4)}` : "UNK"); + + return ( + + + + {`Location: ${longName} (${shortName})`} + + +
+ +

+ Coordinates:{" "} + + {location?.data.latitudeI / 1e7},{" "} + {location?.data.longitudeI / 1e7} + +

+

Altitude: {location?.data.altitude}m

+
+
+
+
+
+ ); +}; diff --git a/src/components/Dialog/NodeDetailsDialog.tsx b/src/components/Dialog/NodeDetailsDialog.tsx new file mode 100644 index 00000000..80c032bc --- /dev/null +++ b/src/components/Dialog/NodeDetailsDialog.tsx @@ -0,0 +1,163 @@ +import { useAppStore } from "@app/core/stores/appStore"; +import { useDevice } from "@app/core/stores/deviceStore"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@components/UI/Accordion"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@components/UI/Dialog"; +import { Protobuf } from "@meshtastic/js"; +import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; +import { DeviceImage } from "../generic/DeviceImage"; +import { TimeAgo } from "../generic/TimeAgo"; +import { Uptime } from "../generic/Uptime"; + +export interface NodeDetailsDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export const NodeDetailsDialog = ({ + open, + onOpenChange, +}: NodeDetailsDialogProps) => { + const { nodes } = useDevice(); + const { nodeNumDetails } = useAppStore(); + const device: Protobuf.Mesh.NodeInfo = nodes.get(nodeNumDetails); + + return device ? ( + + + + + Node Details for {device.user?.longName ?? "UNKNOWN"} ( + {device.user?.shortName ?? "UNK"}) + + + +
+ +
+

+ Details: +

+

+ Hardware:{" "} + {Protobuf.Mesh.HardwareModel[device.user?.hwModel ?? 0]} +

+

Node Number: {device.num}

+

Node HEX: !{numberToHexUnpadded(device.num)}

+

+ Role:{" "} + { + Protobuf.Config.Config_DeviceConfig_Role[ + device.user?.role ?? 0 + ] + } +

+

+ Last Heard:{" "} + {device.lastHeard === 0 ? ( + "Never" + ) : ( + + )} +

+
+ + {device.position ? ( +
+

+ Position: +

+ {device.position.latitudeI && device.position.longitudeI ? ( +

+ Coordinates:{" "} + + {device.position.latitudeI / 1e7},{" "} + {device.position.longitudeI / 1e7} + +

+ ) : null} + {device.position.altitude ? ( +

Altitude: {device.position.altitude}m

+ ) : null} +
+ ) : null} + + {device.deviceMetrics ? ( +
+

+ Device Metrics: +

+ {device.deviceMetrics.airUtilTx ? ( +

+ Air TX utilization:{" "} + {device.deviceMetrics.airUtilTx.toFixed(2)}% +

+ ) : null} + {device.deviceMetrics.channelUtilization ? ( +

+ Channel utilization:{" "} + {device.deviceMetrics.channelUtilization.toFixed(2)}% +

+ ) : null} + {device.deviceMetrics.batteryLevel ? ( +

+ Battery level:{" "} + {device.deviceMetrics.batteryLevel.toFixed(2)}% +

+ ) : null} + {device.deviceMetrics.voltage ? ( +

Voltage: {device.deviceMetrics.voltage.toFixed(2)}V

+ ) : null} + {device.deviceMetrics.uptimeSeconds ? ( +

+ Uptime:{" "} + +

+ ) : null} +
+ ) : null} + + {device ? ( +
+ + + +

+ All Raw Metrics: +

+
+ +
+                        {JSON.stringify(device, null, 2)}
+                      
+
+
+
+
+ ) : null} +
+
+
+
+ ) : null; +}; diff --git a/src/components/Dialog/NodeOptionsDialog.tsx b/src/components/Dialog/NodeOptionsDialog.tsx new file mode 100644 index 00000000..d62e4065 --- /dev/null +++ b/src/components/Dialog/NodeOptionsDialog.tsx @@ -0,0 +1,117 @@ +import { toast } from "@app/core/hooks/useToast"; +import { useAppStore } from "@app/core/stores/appStore"; +import { useDevice } from "@app/core/stores/deviceStore"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@components/UI/Dialog"; +import type { Protobuf } from "@meshtastic/js"; +import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; +import { TrashIcon } from "lucide-react"; +import type { JSX } from "react"; +import { Button } from "../UI/Button"; + +export interface NodeOptionsDialogProps { + node: Protobuf.Mesh.NodeInfo | undefined; + open: boolean; + onOpenChange: () => void; +} + +export const NodeOptionsDialog = ({ + node, + open, + onOpenChange, +}: NodeOptionsDialogProps): JSX.Element => { + const { setDialogOpen, connection, setActivePage } = useDevice(); + const { + setNodeNumToBeRemoved, + setNodeNumDetails, + setChatType, + setActiveChat, + } = useAppStore(); + const longName = + node?.user?.longName ?? + (node ? `!${numberToHexUnpadded(node?.num)}` : "Unknown"); + const shortName = + node?.user?.shortName ?? + (node ? `${numberToHexUnpadded(node?.num).substring(0, 4)}` : "UNK"); + + function handleDirectMessage() { + if (!node) return; + setChatType("direct"); + setActiveChat(node.num); + setActivePage("messages"); + } + + function handleRequestPosition() { + if (!node) return; + toast({ + title: "Requesting position, please wait...", + }); + connection?.requestPosition(node.num).then(() => + toast({ + title: "Position request sent.", + }), + ); + onOpenChange(); + } + + function handleTraceroute() { + if (!node) return; + toast({ + title: "Sending Traceroute, please wait...", + }); + connection?.traceRoute(node.num).then(() => + toast({ + title: "Traceroute sent.", + }), + ); + onOpenChange(); + } + + return ( + + + + {`${longName} (${shortName})`} + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ ); +}; diff --git a/src/components/Dialog/TracerouteResponseDialog.tsx b/src/components/Dialog/TracerouteResponseDialog.tsx new file mode 100644 index 00000000..9fce1cfa --- /dev/null +++ b/src/components/Dialog/TracerouteResponseDialog.tsx @@ -0,0 +1,57 @@ +import { useDevice } from "@app/core/stores/deviceStore"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@components/UI/Dialog"; +import type { Protobuf, Types } from "@meshtastic/js"; +import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; +import type { JSX } from "react"; +import { TraceRoute } from "../PageComponents/Messages/TraceRoute"; + +export interface TracerouteResponseDialogProps { + traceroute: Types.PacketMetadata | undefined; + open: boolean; + onOpenChange: () => void; +} + +export const TracerouteResponseDialog = ({ + traceroute, + open, + onOpenChange, +}: TracerouteResponseDialogProps): JSX.Element => { + const { nodes } = useDevice(); + const route: number[] = traceroute?.data.route ?? []; + const routeBack: number[] = traceroute?.data.routeBack ?? []; + const snrTowards = traceroute?.data.snrTowards ?? []; + const snrBack = traceroute?.data.snrBack ?? []; + const from = nodes.get(traceroute?.from ?? 0); + const longName = + from?.user?.longName ?? + (from ? `!${numberToHexUnpadded(from?.num)}` : "Unknown"); + const shortName = + from?.user?.shortName ?? + (from ? `${numberToHexUnpadded(from?.num).substring(0, 4)}` : "UNK"); + const to = nodes.get(traceroute?.to ?? 0); + return ( + + + + {`Traceroute: ${longName} (${shortName})`} + + + + + + + ); +}; diff --git a/src/components/Form/DynamicFormField.tsx b/src/components/Form/DynamicFormField.tsx index 388836d4..545ff0a7 100644 --- a/src/components/Form/DynamicFormField.tsx +++ b/src/components/Form/DynamicFormField.tsx @@ -1,3 +1,7 @@ +import { + type MultiSelectFieldProps, + MultiSelectInput, +} from "@app/components/Form/FormMultiSelect"; import { GenericInput, type InputFieldProps, @@ -19,6 +23,7 @@ import type { Control, FieldValues } from "react-hook-form"; export type FieldProps = | InputFieldProps | SelectFieldProps + | MultiSelectFieldProps | ToggleFieldProps | PasswordGeneratorProps; @@ -58,6 +63,8 @@ export function DynamicFormField({ /> ); case "multiSelect": - return
tmp
; + return ( + + ); } } diff --git a/src/components/Form/FormMultiSelect.tsx b/src/components/Form/FormMultiSelect.tsx new file mode 100644 index 00000000..1ca20575 --- /dev/null +++ b/src/components/Form/FormMultiSelect.tsx @@ -0,0 +1,60 @@ +import type { + BaseFormBuilderProps, + GenericFormElementProps, +} from "@components/Form/DynamicForm.tsx"; +import type { FieldValues } from "react-hook-form"; +import { MultiSelect, MultiSelectItem } from "../UI/MultiSelect"; + +export interface MultiSelectFieldProps extends BaseFormBuilderProps { + type: "multiSelect"; + placeholder?: string; + onValueChange: (name: string) => void; + isChecked: (name: string) => boolean; + value: string[]; + properties: BaseFormBuilderProps["properties"] & { + enumValue: { + [s: string]: string | number; + }; + formatEnumName?: boolean; + }; +} + +export function MultiSelectInput({ + field, +}: GenericFormElementProps>) { + const { enumValue, formatEnumName, ...remainingProperties } = + field.properties; + + // Make sure to filter out the UNSET value, as it shouldn't be shown in the UI + const optionsEnumValues = enumValue + ? Object.entries(enumValue) + .filter((value) => typeof value[1] === "number") + .filter((value) => value[0] !== "UNSET") + : []; + + const formatName = (name: string) => { + if (!formatEnumName) return name; + return name + .replace(/_/g, " ") + .toLowerCase() + .split(" ") + .map((s) => s.charAt(0).toUpperCase() + s.substring(1)) + .join(" "); + }; + + return ( + + {optionsEnumValues.map(([name, value]) => ( + field.onValueChange(name)} + > + {formatEnumName ? formatName(name) : name} + + ))} + + ); +} diff --git a/src/components/Form/FormSelect.tsx b/src/components/Form/FormSelect.tsx index 95405127..bd0e24f0 100644 --- a/src/components/Form/FormSelect.tsx +++ b/src/components/Form/FormSelect.tsx @@ -12,7 +12,7 @@ import { import { Controller, type FieldValues } from "react-hook-form"; export interface SelectFieldProps extends BaseFormBuilderProps { - type: "select" | "multiSelect"; + type: "select"; properties: BaseFormBuilderProps["properties"] & { enumValue: { [s: string]: string | number; @@ -51,7 +51,7 @@ export function SelectInput({ {optionsEnumValues.map(([name, value]) => ( - + {formatEnumName ? name .replace(/_/g, " ") diff --git a/src/components/PageComponents/Config/Device.tsx b/src/components/PageComponents/Config/Device.tsx index 9ef11904..17ee8cb2 100644 --- a/src/components/PageComponents/Config/Device.tsx +++ b/src/components/PageComponents/Config/Device.tsx @@ -46,7 +46,7 @@ export const Device = (): JSX.Element => { "Lost and Found": Protobuf.Config.Config_DeviceConfig_Role.LOST_AND_FOUND, "TAK Tracker": - Protobuf.Config.Config_DeviceConfig_Role.SENSOR, + Protobuf.Config.Config_DeviceConfig_Role.TAK_TRACKER, }, formatEnumName: true, }, diff --git a/src/components/PageComponents/Config/Position.tsx b/src/components/PageComponents/Config/Position.tsx index 798da2e9..e81af178 100644 --- a/src/components/PageComponents/Config/Position.tsx +++ b/src/components/PageComponents/Config/Position.tsx @@ -1,25 +1,43 @@ +import { + type FlagName, + usePositionFlags, +} from "@app/core/hooks/usePositionFlags"; import type { PositionValidation } from "@app/validation/config/position.tsx"; import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; import { Protobuf } from "@meshtastic/js"; +import { useCallback } from "react"; -export const Position = (): JSX.Element => { - const { config, nodes, hardware, setWorkingConfig } = useDevice(); +export const Position = () => { + const { config, setWorkingConfig } = useDevice(); + const { flagsValue, activeFlags, toggleFlag, getAllFlags } = usePositionFlags( + config.position.positionFlags ?? 0, + ); const onSubmit = (data: PositionValidation) => { - setWorkingConfig( + return setWorkingConfig( new Protobuf.Config.Config({ payloadVariant: { case: "position", - value: data, + value: { ...data, positionFlags: flagsValue }, }, }), ); }; + const onPositonFlagChange = useCallback( + (name: string) => { + return toggleFlag(name as FlagName); + }, + [toggleFlag], + ); + return ( - onSubmit={onSubmit} + onSubmit={(data) => { + data.positionFlags = flagsValue; + return onSubmit(data); + }} defaultValues={config.position} fieldGroups={[ { @@ -53,10 +71,16 @@ export const Position = (): JSX.Element => { { type: "multiSelect", name: "positionFlags", + value: activeFlags, + isChecked: (name: string) => + activeFlags.includes(name as FlagName), + onValueChange: onPositonFlagChange, label: "Position Flags", - description: "Configuration options for Position messages", + placeholder: "Select position flags...", + description: + "Optional fields to include when assembling position messages. The more fields are selected, the larger the message will be leading to longer airtime usage and a higher risk of packet loss.", properties: { - enumValue: Protobuf.Config.Config_PositionConfig_PositionFlags, + enumValue: getAllFlags(), }, }, { diff --git a/src/components/PageComponents/Connect/HTTP.tsx b/src/components/PageComponents/Connect/HTTP.tsx index df4650bd..4fb3be6a 100644 --- a/src/components/PageComponents/Connect/HTTP.tsx +++ b/src/components/PageComponents/Connect/HTTP.tsx @@ -23,8 +23,8 @@ export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => { window.location.hostname, ) ? "meshtastic.local" - : window.location.hostname, - tls: false, + : window.location.host, + tls: location.protocol === "https:", }, }); diff --git a/src/components/PageComponents/Map/NodeDetail.tsx b/src/components/PageComponents/Map/NodeDetail.tsx index 5203d077..2b74db0e 100644 --- a/src/components/PageComponents/Map/NodeDetail.tsx +++ b/src/components/PageComponents/Map/NodeDetail.tsx @@ -1,9 +1,10 @@ import { Separator } from "@app/components/UI/Seperator"; import { H5 } from "@app/components/UI/Typography/H5.tsx"; import { Subtle } from "@app/components/UI/Typography/Subtle.tsx"; +import { formatQuantity } from "@app/core/utils/string"; import { Avatar } from "@components/UI/Avatar"; import { Mono } from "@components/generic/Mono.tsx"; -import { TimeAgo } from "@components/generic/Table/tmp/TimeAgo.tsx"; +import { TimeAgo } from "@components/generic/TimeAgo.tsx"; import { Protobuf } from "@meshtastic/js"; import type { Protobuf as ProtobufType } from "@meshtastic/js"; import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; @@ -25,12 +26,12 @@ export interface NodeDetailProps { export const NodeDetail = ({ node }: NodeDetailProps) => { const name = node.user?.longName || `!${numberToHexUnpadded(node.num)}`; - const hardwareType = Protobuf.Mesh.HardwareModel[ - node.user?.hwModel ?? 0 - ].replaceAll("_", " "); + const hwModel = node.user?.hwModel ?? 0; + const hardwareType = + Protobuf.Mesh.HardwareModel[hwModel]?.replaceAll("_", " ") ?? `${hwModel}`; return ( -
+
@@ -132,7 +133,12 @@ export const NodeDetail = ({ node }: NodeDetailProps) => { className="ml-2 mr-1" aria-label="Elevation" /> -
{node.position?.altitude} ft
+
+ {formatQuantity(node.position?.altitude, { + one: "meter", + other: "meters", + })} +
)}
diff --git a/src/components/PageComponents/Messages/ChannelChat.tsx b/src/components/PageComponents/Messages/ChannelChat.tsx index 79950bf8..cdbca329 100644 --- a/src/components/PageComponents/Messages/ChannelChat.tsx +++ b/src/components/PageComponents/Messages/ChannelChat.tsx @@ -1,75 +1,88 @@ -import { Subtle } from "@app/components/UI/Typography/Subtle.tsx"; import { type MessageWithState, useDevice, } from "@app/core/stores/deviceStore.ts"; import { Message } from "@components/PageComponents/Messages/Message.tsx"; import { MessageInput } from "@components/PageComponents/Messages/MessageInput.tsx"; -import { TraceRoute } from "@components/PageComponents/Messages/TraceRoute.tsx"; -import type { Protobuf, Types } from "@meshtastic/js"; +import type { Types } from "@meshtastic/js"; import { InboxIcon } from "lucide-react"; +import { useCallback, useEffect, useRef } from "react"; +import type { JSX } from "react"; export interface ChannelChatProps { messages?: MessageWithState[]; channel: Types.ChannelNumber; to: Types.Destination; - traceroutes?: Types.PacketMetadata[]; } +const EmptyState = () => ( +
+ + No Messages +
+); + export const ChannelChat = ({ messages, channel, to, - traceroutes, }: ChannelChatProps): JSX.Element => { const { nodes } = useDevice(); + const messagesEndRef = useRef(null); + const scrollContainerRef = useRef(null); + + const scrollToBottom = useCallback(() => { + const scrollContainer = scrollContainerRef.current; + if (scrollContainer) { + const isNearBottom = + scrollContainer.scrollHeight - + scrollContainer.scrollTop - + scrollContainer.clientHeight < + 100; + if (isNearBottom) { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + } + } + }, []); + + useEffect(() => { + scrollToBottom(); + }, [scrollToBottom]); + + if (!messages?.length) { + return ( +
+
+ +
+
+ +
+
+ ); + } return ( -
-
-
- {messages ? ( - messages.map((message, index) => ( +
+
+
+ {messages.map((message, index) => { + return ( 0 && messages[index - 1].from === message.from + } /> - )) - ) : ( -
- - No Messages -
- )} -
-
- {to === "broadcast" ? null : traceroutes ? ( - traceroutes.map((traceroute, index) => ( - - )) - ) : ( -
- - No Traceroutes -
- )} + ); + })} +
-
- +
+
); diff --git a/src/components/PageComponents/Messages/Message.tsx b/src/components/PageComponents/Messages/Message.tsx index e8030e2a..a6eb8f9a 100644 --- a/src/components/PageComponents/Messages/Message.tsx +++ b/src/components/PageComponents/Messages/Message.tsx @@ -1,73 +1,168 @@ -import type { MessageWithState } from "@app/core/stores/deviceStore.ts"; +import { + Tooltip, + TooltipArrow, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@app/components/UI/Tooltip"; +import { useAppStore } from "@app/core/stores/appStore"; +import { + type MessageWithState, + useDeviceStore, +} from "@app/core/stores/deviceStore.ts"; +import { cn } from "@app/core/utils/cn"; import { Avatar } from "@components/UI/Avatar"; import type { Protobuf } from "@meshtastic/js"; -import { - AlertCircleIcon, - CheckCircle2Icon, - CircleEllipsisIcon, -} from "lucide-react"; +import { AlertCircle, CheckCircle2, CircleEllipsis } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; +import { useMemo } from "react"; -export interface MessageProps { +const MESSAGE_STATES = { + ACK: "ack", + WAITING: "waiting", + FAILED: "failed", +} as const; + +type MessageState = MessageWithState["state"]; + +interface MessageProps { lastMsgSameUser: boolean; message: MessageWithState; - sender?: Protobuf.Mesh.NodeInfo; + sender: Protobuf.Mesh.NodeInfo; } -export const Message = ({ lastMsgSameUser, message, sender }: MessageProps) => { - return lastMsgSameUser ? ( -
- {message.state === "ack" ? ( - - ) : message.state === "waiting" ? ( - - ) : ( - - )} - = { + [MESSAGE_STATES.ACK]: "Message delivered", + [MESSAGE_STATES.WAITING]: "Waiting for delivery", + [MESSAGE_STATES.FAILED]: "Delivery failed", +} as const; + +const STATUS_ICON_MAP: Record = { + [MESSAGE_STATES.ACK]: CheckCircle2, + [MESSAGE_STATES.WAITING]: CircleEllipsis, + [MESSAGE_STATES.FAILED]: AlertCircle, +} as const; + +const getStatusText = (state: MessageState): string => STATUS_TEXT_MAP[state]; + +const StatusTooltip = ({ state, children }: StatusTooltipProps) => ( + + + {children} + - {message.data} - -
- ) : ( -
-
-
- -
- - {sender?.user?.longName ?? "UNK"} - - - {message.rxTime.toLocaleDateString()} - - - {message.rxTime.toLocaleTimeString(undefined, { - hour: "2-digit", - minute: "2-digit", - })} - -
-
- {message.state === "ack" ? ( - - ) : message.state === "waiting" ? ( - - ) : ( - + {getStatusText(state)} + + + + +); + +const StatusIcon = ({ state, className, ...otherProps }: StatusIconProps) => { + const isFailed = state === MESSAGE_STATES.FAILED; + const iconClass = cn( + className, + "text-gray-500 dark:text-gray-400 w-4 h-4 flex-shrink-0", + ); + + const Icon = STATUS_ICON_MAP[state]; + return ( + + + + ); +}; + +const getMessageTextStyles = (state: MessageState) => { + const isAcknowledged = state === MESSAGE_STATES.ACK; + const isFailed = state === MESSAGE_STATES.FAILED; + const isWaiting = state === MESSAGE_STATES.WAITING; + + return cn( + "break-words overflow-hidden", + isAcknowledged + ? "text-black dark:text-white" + : "text-black dark:text-gray-400", + isFailed && "text-red-500 dark:text-red-500", + ); +}; + +const TimeDisplay = ({ + date, + className, +}: { date: Date; className?: string }) => ( +
+ + {date.toLocaleDateString()} + + + {date.toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + })} + +
+); + +export const Message = ({ lastMsgSameUser, message, sender }: MessageProps) => { + const { getDevices } = useDeviceStore(); + + const isDeviceUser = useMemo( + () => + getDevices() + .map((device) => device.nodes.get(device.hardware.myNodeNum)?.num) + .includes(message.from), + [getDevices, message.from], + ); + const messageUser = sender?.user; + + const messageTextClass = getMessageTextStyles(message.state); + + return ( +
+
- {message.data} - + > +
+ {!lastMsgSameUser ? ( +
+ +
+ + {messageUser?.longName} + +
+
+ ) : null} +
+ +
+
+ {message.data} +
+ +
); diff --git a/src/components/PageComponents/Messages/MessageInput.tsx b/src/components/PageComponents/Messages/MessageInput.tsx index ae88f9ac..e8b85357 100644 --- a/src/components/PageComponents/Messages/MessageInput.tsx +++ b/src/components/PageComponents/Messages/MessageInput.tsx @@ -4,16 +4,24 @@ import { Input } from "@components/UI/Input.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; import type { Types } from "@meshtastic/js"; import { SendIcon } from "lucide-react"; -import { useCallback, useMemo, useState } from "react"; +import { + type JSX, + startTransition, + useCallback, + useMemo, + useState, +} from "react"; export interface MessageInputProps { to: Types.Destination; channel: Types.ChannelNumber; + maxBytes: number; } export const MessageInput = ({ to, channel, + maxBytes, }: MessageInputProps): JSX.Element => { const { connection, @@ -24,6 +32,7 @@ export const MessageInput = ({ } = useDevice(); const myNodeNum = hardware.myNodeNum; const [localDraft, setLocalDraft] = useState(messageDraft); + const [messageBytes, setMessageBytes] = useState(0); const debouncedSetMessageDraft = useMemo( () => debounce(setMessageDraft, 300), @@ -60,19 +69,29 @@ export const MessageInput = ({ const handleInputChange = (e: React.ChangeEvent) => { const newValue = e.target.value; - setLocalDraft(newValue); - debouncedSetMessageDraft(newValue); + const byteLength = new Blob([newValue]).size; + + if (byteLength <= maxBytes) { + setLocalDraft(newValue); + debouncedSetMessageDraft(newValue); + setMessageBytes(byteLength); + } }; return (
{ - e.preventDefault(); - sendText(localDraft); - setLocalDraft(""); - setMessageDraft(""); + action={async (formData: FormData) => { + // prevent user from sending blank/empty message + if (localDraft === "") return; + const message = formData.get("messageInput") as string; + startTransition(() => { + sendText(message); + setLocalDraft(""); + setMessageDraft(""); + setMessageBytes(0); + }); }} >
@@ -80,11 +99,16 @@ export const MessageInput = ({ +
+ {messageBytes}/{maxBytes} +
+ diff --git a/src/components/PageComponents/Messages/TraceRoute.tsx b/src/components/PageComponents/Messages/TraceRoute.tsx index 5b768580..11c77332 100644 --- a/src/components/PageComponents/Messages/TraceRoute.tsx +++ b/src/components/PageComponents/Messages/TraceRoute.tsx @@ -1,36 +1,60 @@ import { useDevice } from "@app/core/stores/deviceStore.ts"; import type { Protobuf } from "@meshtastic/js"; import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; +import type { JSX } from "react"; export interface TraceRouteProps { from?: Protobuf.Mesh.NodeInfo; to?: Protobuf.Mesh.NodeInfo; route: Array; + routeBack?: Array; + snrTowards?: Array; + snrBack?: Array; } export const TraceRoute = ({ from, to, route, + routeBack, + snrTowards, + snrBack, }: TraceRouteProps): JSX.Element => { const { nodes } = useDevice(); - return route.length === 0 ? ( + return (
- {to?.user?.longName}↔{from?.user?.longName} - -
- ) : ( -
- - {to?.user?.longName}↔ - {route.map((hop) => { - const node = nodes.get(hop); - return `${node?.user?.longName ?? (node?.num ? numberToHexUnpadded(node.num) : "Unknown")}↔`; - })} +

Route to destination:

+

{to?.user?.longName}

+

↓ {snrTowards?.[0] ? snrTowards[0] : "??"}dB

+ {route.map((hop, i) => ( + +

+ {nodes.get(hop)?.user?.longName ?? `!${numberToHexUnpadded(hop)}`} +

+

↓ {snrTowards?.[i + 1] ? snrTowards[i + 1] : "??"}dB

+
+ ))} {from?.user?.longName}
+ {routeBack ? ( + +

Route back:

+

{from?.user?.longName}

+

↓ {snrBack?.[0] ? snrBack[0] : "??"}dB

+ {routeBack.map((hop, i) => ( + +

+ {nodes.get(hop)?.user?.longName ?? + `!${numberToHexUnpadded(hop)}`} +

+

↓ {snrBack?.[i + 1] ? snrBack[i + 1] : "??"}dB

+
+ ))} + {to?.user?.longName} +
+ ) : null}
); }; diff --git a/src/components/PageLayout.tsx b/src/components/PageLayout.tsx index 79495bdc..1009ab54 100644 --- a/src/components/PageLayout.tsx +++ b/src/components/PageLayout.tsx @@ -35,7 +35,7 @@ export const PageLayout = ({
{actions?.map((action, index) => ( ); diff --git a/src/components/UI/Toast.tsx b/src/components/UI/Toast.tsx index b2fd7131..c0eba845 100644 --- a/src/components/UI/Toast.tsx +++ b/src/components/UI/Toast.tsx @@ -28,7 +28,7 @@ const toastVariants = cva( variants: { variant: { default: - "border bg-background text-foreground dark:bg-slate-700 dark:border-slate-600 dark:text-slate-50", + "border bg-backgroundPrimary text-foreground dark:bg-slate-700 dark:border-slate-600 dark:text-slate-50", destructive: "group destructive bg-red-600 text-white dark:border-red-900 dark:bg-red-900 dark:text-red-50", }, diff --git a/src/components/UI/Tooltip.tsx b/src/components/UI/Tooltip.tsx index bde4b344..2353418a 100644 --- a/src/components/UI/Tooltip.tsx +++ b/src/components/UI/Tooltip.tsx @@ -9,6 +9,7 @@ const Tooltip = ({ ...props }) => ; Tooltip.displayName = TooltipPrimitive.Tooltip.displayName; const TooltipTrigger = TooltipPrimitive.Trigger; +const TooltipArrow = TooltipPrimitive.Arrow; const TooltipContent = React.forwardRef< React.ElementRef, @@ -26,4 +27,10 @@ const TooltipContent = React.forwardRef< )); TooltipContent.displayName = TooltipPrimitive.Content.displayName; -export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; +export { + Tooltip, + TooltipTrigger, + TooltipContent, + TooltipProvider, + TooltipArrow, +}; diff --git a/src/components/generic/DeviceImage.tsx b/src/components/generic/DeviceImage.tsx new file mode 100644 index 00000000..ef96a429 --- /dev/null +++ b/src/components/generic/DeviceImage.tsx @@ -0,0 +1,53 @@ +export interface DeviceImageProps { + deviceType: string; + className?: React.HTMLAttributes["className"]; +} + +const hardwareModelToFilename: { [key: string]: string } = { + DIY_V1: "diy.svg", + NANO_G2_ULTRA: "nano-g2-ultra.svg", + TBEAM: "tbeam.svg", + HELTEC_HT62: "heltec-ht62-esp32c3-sx1262.svg", + RPI_PICO: "pico.svg", + T_DECK: "t-deck.svg", + HELTEC_MESH_NODE_T114: "heltec-mesh-node-t114.svg", + HELTEC_MESH_NODE_T114_CASE: "heltec-mesh-node-t114-case.svg", + HELTEC_V3: "heltec-v3.svg", + HELTEC_V3_CASE: "heltec-v3-case.svg", + HELTEC_VISION_MASTER_E213: "heltec-vision-master-e213.svg", + HELTEC_VISION_MASTER_E290: "heltec-vision-master-e290.svg", + HELTEC_VISION_MASTER_T190: "heltec-vision-master-t190.svg", + HELTEC_WIRELESS_PAPER: "heltec-wireless-paper.svg", + HELTEC_WIRELESS_PAPER_V1_0: "heltec-wireless-paper-V1_0.svg", + HELTEC_WIRELESS_TRACKER: "heltec-wireless-tracker.svg", + HELTEC_WIRELESS_TRACKER_V1_0: "heltec-wireless-tracker-V1-0.svg", + HELTEC_WSL_V3: "heltec-wsl-v3.svg", + TLORA_C6: "tlora-c6.svg", + TLORA_T3_S3: "tlora-t3s3-v1.svg", + TLORA_T3_S3_EPAPER: "tlora-t3s3-epaper.svg", + TLORA_V2: "tlora-v2-1-1_6.svg", + TLORA_V2_1_1P6: "tlora-v2-1-1_6.svg", + TLORA_V2_1_1P8: "tlora-v2-1-1_8.svg", + RAK11310: "rak11310.svg", + RAK2560: "rak2560.svg", + RAK4631: "rak4631.svg", + RAK4631_CASE: "rak4631_case.svg", + WIO_WM1110: "wio-tracker-wm1110.svg", + WM1110_DEV_KIT: "wm1110_dev_kit.svg", + STATION_G2: "station-g2.svg", + TBEAM_V0P7: "tbeam-s3-core.svg", + T_ECHO: "t-echo.svg", + TRACKER_T1000_E: "tracker-t1000-e.svg", + T_WATCH_S3: "t-watch-s3.svg", + SEEED_XIAO_S3: "seeed-xiao-s3.svg", + SENSECAP_INDICATOR: "seeed-sensecap-indicator.svg", + PROMICRO: "promicro.svg", + RPIPICOW: "rpipicow.svg", + UNKNOWN: "unknown.svg", +}; + +export const DeviceImage = ({ deviceType, className }: DeviceImageProps) => { + const getPath = (device: string) => `/devices/${device}`; + const device = hardwareModelToFilename[deviceType] || "unknown.svg"; + return {device}; +}; diff --git a/src/components/generic/Table/tmp/TimeAgo.tsx b/src/components/generic/Table/tmp/TimeAgo.tsx deleted file mode 100755 index 32a766ff..00000000 --- a/src/components/generic/Table/tmp/TimeAgo.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import TimeAgoReact from "timeago-react"; - -export interface TimeAgoProps { - timestamp: number; -} - -export const TimeAgo = ({ timestamp }: TimeAgoProps): JSX.Element => { - return ; -}; diff --git a/src/components/generic/TimeAgo.tsx b/src/components/generic/TimeAgo.tsx new file mode 100755 index 00000000..85043c58 --- /dev/null +++ b/src/components/generic/TimeAgo.tsx @@ -0,0 +1,66 @@ +import { + Tooltip, + TooltipContent, + TooltipPortal, + TooltipProvider, + TooltipTrigger, +} from "@radix-ui/react-tooltip"; +import type { JSX } from "react"; + +export interface TimeAgoProps { + timestamp: number; +} + +const getTimeAgo = ( + unixTimestamp: number, + locale: Intl.LocalesArgument = "en", +): string => { + const timestamp = new Date(unixTimestamp); + const diff = (new Date().getTime() - timestamp.getTime()) / 1000; + + const minutes = Math.floor(diff / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + const months = Math.floor(days / 30); + const years = Math.floor(months / 12); + const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }); + + if (years > 0) { + return rtf.format(0 - years, "year"); + } + if (months > 0) { + return rtf.format(0 - months, "month"); + } + if (days > 0) { + return rtf.format(0 - days, "day"); + } + if (hours > 0) { + return rtf.format(0 - hours, "hour"); + } + if (minutes > 0) { + return rtf.format(0 - minutes, "minute"); + } + return rtf.format(Math.floor(0 - diff), "second"); +}; + +export const TimeAgo = ({ timestamp }: TimeAgoProps): JSX.Element => { + return ( + + + + {getTimeAgo(timestamp)} + + + + {new Date(timestamp).toLocaleString()} + + + + + ); +}; diff --git a/src/components/generic/Uptime.tsx b/src/components/generic/Uptime.tsx new file mode 100644 index 00000000..fdcf897a --- /dev/null +++ b/src/components/generic/Uptime.tsx @@ -0,0 +1,17 @@ +import type { JSX } from "react"; + +export interface UptimeProps { + seconds: number; +} + +const getUptime = (seconds: number): string => { + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + const minutes = Math.floor(((seconds % 86400) % 3600) / 60); + const secondsLeft = Math.floor(((seconds % 86400) % 3600) % 60); + return `${days}d ${hours}h ${minutes}m ${secondsLeft}s`; +}; + +export const Uptime = ({ seconds }: UptimeProps): JSX.Element => { + return {getUptime(seconds)}; +}; diff --git a/src/core/hooks/usePositionFlags.ts b/src/core/hooks/usePositionFlags.ts new file mode 100644 index 00000000..95459372 --- /dev/null +++ b/src/core/hooks/usePositionFlags.ts @@ -0,0 +1,120 @@ +import { useCallback, useMemo, useState } from "react"; + +const FLAGS = { + UNSET: 0, + Altitude: 1, + "Altitude is Mean Sea Level": 2, + "Altitude Geoidal Seperation": 4, + "Dilution of precision (DOP) PDOP used by default": 8, + "If DOP is set, use HDOP / VDOP values instead of PDOP": 16, + "Number of satellites": 32, + "Sequence number": 64, + Timestamp: 128, + "Vehicle heading": 256, + "Vehicle speed": 512, +} as const; + +export type FlagName = keyof typeof FLAGS; +type FlagsObject = typeof FLAGS; + +type UsePositionFlagsProps = { + decode: (value: number) => FlagName[]; + encode: (flagNames: FlagName[]) => number; + hasFlag: (value: number, flagName: FlagName) => boolean; + getAllFlags: () => FlagsObject; + isValidValue: (value: number) => boolean; + flagsValue: number; + activeFlags: FlagName[]; + toggleFlag: (flagName: FlagName) => void; + setFlag: (flagName: FlagName, enabled: boolean) => void; + setFlags: (value: number) => void; + clearFlags: () => void; +}; + +export const usePositionFlags = (initialValue = 0): UsePositionFlagsProps => { + const [flagsValue, setFlagsValue] = useState(initialValue); + + const utils = useMemo(() => { + const decode = (value: number): FlagName[] => { + if (value === 0) return ["UNSET"]; + + const activeFlags: FlagName[] = []; + for (const [name, flagValue] of Object.entries(FLAGS)) { + if (flagValue !== 0 && (value & flagValue) === flagValue) { + activeFlags.push(name as FlagName); + } + } + return activeFlags; + }; + + const encode = (flagNames: FlagName[]): number => { + if (flagNames.includes("UNSET")) { + return 0; + } + return flagNames.reduce((acc, name) => { + const value = FLAGS[name]; + return acc | value; + }, 0); + }; + + const hasFlag = (value: number, flagName: FlagName): boolean => { + const flagValue = FLAGS[flagName]; + return (value & flagValue) === flagValue; + }; + + const getAllFlags = (): FlagsObject => { + return FLAGS; + }; + + const isValidValue = (value: number): boolean => { + const maxValue = Object.values(FLAGS) + .filter((val) => val !== 0) // Exclude UNSET (0) from the calculation + .reduce((acc, val) => acc | val, 0); + return Number.isInteger(value) && value >= 0 && value <= maxValue; + }; + + return { + decode, + encode, + hasFlag, + getAllFlags, + isValidValue, + }; + }, []); + + const toggleFlag = useCallback((flagName: FlagName) => { + const flagValue = FLAGS[flagName]; + setFlagsValue((prev) => prev ^ flagValue); + }, []); + + const setFlag = useCallback((flagName: FlagName, enabled: boolean) => { + const flagValue = FLAGS[flagName]; + setFlagsValue((prev) => (enabled ? prev | flagValue : prev & ~flagValue)); + }, []); + + const setFlags = useCallback( + (value: number) => { + if (!utils.isValidValue(value)) { + throw new Error(`Invalid flags value: ${value}`); + } + setFlagsValue(value); + }, + [utils], + ); + + const clearFlags = useCallback(() => { + setFlagsValue(0); + }, []); + + const activeFlags = utils.decode(flagsValue); + + return { + ...utils, + flagsValue, + activeFlags, + toggleFlag, + setFlag, + setFlags, + clearFlags, + }; +}; diff --git a/src/core/stores/appStore.ts b/src/core/stores/appStore.ts index c7bc2c62..d9e213c8 100644 --- a/src/core/stores/appStore.ts +++ b/src/core/stores/appStore.ts @@ -1,3 +1,4 @@ +import { Types } from "@meshtastic/js"; import { produce } from "immer"; import { create } from "zustand"; @@ -29,6 +30,9 @@ interface AppState { nodeNumToBeRemoved: number; accent: AccentColor; connectDialogOpen: boolean; + nodeNumDetails: number; + activeChat: number; + chatType: "broadcast" | "direct"; setRasterSources: (sources: RasterSource[]) => void; addRasterSource: (source: RasterSource) => void; @@ -42,6 +46,9 @@ interface AppState { setNodeNumToBeRemoved: (nodeNum: number) => void; setAccent: (color: AccentColor) => void; setConnectDialogOpen: (open: boolean) => void; + setNodeNumDetails: (nodeNum: number) => void; + setActiveChat: (chat: number) => void; + setChatType: (type: "broadcast" | "direct") => void; } export const useAppStore = create()((set) => ({ @@ -57,6 +64,9 @@ export const useAppStore = create()((set) => ({ accent: "orange", connectDialogOpen: false, nodeNumToBeRemoved: 0, + nodeNumDetails: 0, + activeChat: Types.ChannelNumber.Primary, + chatType: "broadcast", setRasterSources: (sources: RasterSource[]) => { set( @@ -124,4 +134,16 @@ export const useAppStore = create()((set) => ({ }), ); }, + setNodeNumDetails: (nodeNum) => + set((state) => ({ + nodeNumDetails: nodeNum, + })), + setActiveChat: (chat) => + set(() => ({ + activeChat: chat, + })), + setChatType: (type) => + set(() => ({ + chatType: type, + })), })); diff --git a/src/core/stores/deviceStore.ts b/src/core/stores/deviceStore.ts index bd407611..a4ad7b69 100644 --- a/src/core/stores/deviceStore.ts +++ b/src/core/stores/deviceStore.ts @@ -26,7 +26,8 @@ export type DialogVariant = | "reboot" | "deviceName" | "nodeRemoval" - | "pkiBackup"; + | "pkiBackup" + | "nodeDetails"; export interface Device { id: number; @@ -62,6 +63,7 @@ export interface Device { deviceName: boolean; nodeRemoval: boolean; pkiBackup: boolean; + nodeDetails: boolean; }; setStatus: (status: Types.DeviceStatusEnum) => void; @@ -145,6 +147,7 @@ export const useDeviceStore = create((set, get) => ({ deviceName: false, nodeRemoval: false, pkiBackup: false, + nodeDetails: false, }, pendingSettingsChanges: false, messageDraft: "", diff --git a/src/core/utils/string.ts b/src/core/utils/string.ts new file mode 100644 index 00000000..2cabf70d --- /dev/null +++ b/src/core/utils/string.ts @@ -0,0 +1,31 @@ +interface PluralForms { + one: string; + other: string; + [key: string]: string; +} + +interface FormatOptions { + locale?: string; + pluralRules?: Intl.PluralRulesOptions; + numberFormat?: Intl.NumberFormatOptions; +} + +export function formatQuantity( + value: number, + forms: PluralForms, + options: FormatOptions = {}, +) { + const { + locale = "en-US", + pluralRules: pluralOptions = { type: "cardinal" }, + numberFormat: numberOptions = {}, + } = options; + + const pluralRules = new Intl.PluralRules(locale, pluralOptions); + const numberFormat = new Intl.NumberFormat(locale, numberOptions); + + const pluralCategory = pluralRules.select(value); + const word = forms[pluralCategory]; + + return `${numberFormat.format(value)} ${word}`; +} diff --git a/src/pages/Dashboard/index.tsx b/src/pages/Dashboard/index.tsx index ee2c4693..34dc7aad 100644 --- a/src/pages/Dashboard/index.tsx +++ b/src/pages/Dashboard/index.tsx @@ -15,8 +15,9 @@ import { import { useMemo } from "react"; export const Dashboard = () => { - const { setConnectDialogOpen } = useAppStore(); + const { setConnectDialogOpen, setSelectedDevice } = useAppStore(); const { getDevices } = useDeviceStore(); + const { darkMode } = useAppStore(); const devices = useMemo(() => getDevices(), [getDevices]); @@ -38,7 +39,13 @@ export const Dashboard = () => { {devices.map((device) => { return (
  • -
    +
    -
  • + ); })} diff --git a/src/pages/Map.tsx b/src/pages/Map.tsx index 12a8e884..606f5be6 100644 --- a/src/pages/Map.tsx +++ b/src/pages/Map.tsx @@ -1,59 +1,93 @@ import { NodeDetail } from "@app/components/PageComponents/Map/NodeDetail"; import { Avatar } from "@app/components/UI/Avatar"; -import { Subtle } from "@app/components/UI/Typography/Subtle.tsx"; -import { cn } from "@app/core/utils/cn.ts"; import { PageLayout } from "@components/PageLayout.tsx"; import { Sidebar } from "@components/Sidebar.tsx"; -import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx"; -import { SidebarButton } from "@components/UI/Sidebar/sidebarButton.tsx"; import { useAppStore } from "@core/stores/appStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts"; import type { Protobuf } from "@meshtastic/js"; -import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; import { bbox, lineString } from "@turf/turf"; -import { - BoxSelectIcon, - MapPinIcon, - ZoomInIcon, - ZoomOutIcon, -} from "lucide-react"; +import { MapPinIcon } from "lucide-react"; import { type JSX, useCallback, useEffect, useMemo, useState } from "react"; -import { AttributionControl, Marker, Popup, useMap } from "react-map-gl"; +import { + AttributionControl, + GeolocateControl, + Marker, + NavigationControl, + Popup, + ScaleControl, + useMap, +} from "react-map-gl"; import MapGl from "react-map-gl/maplibre"; +type NodePosition = { + latitude: number; + longitude: number; +}; + +const convertToLatLng = (position: { + latitudeI?: number; + longitudeI?: number; +}): NodePosition => ({ + latitude: (position.latitudeI ?? 0) / 1e7, + longitude: (position.longitudeI ?? 0) / 1e7, +}); + const MapPage = (): JSX.Element => { const { nodes, waypoints } = useDevice(); - const { rasterSources, darkMode } = useAppStore(); + const { darkMode } = useAppStore(); const { default: map } = useMap(); - const [zoom, setZoom] = useState(0); const [selectedNode, setSelectedNode] = useState(null); - const allNodes = useMemo(() => Array.from(nodes.values()), [nodes]); + // Filter out nodes without a valid position + const validNodes = useMemo( + () => + Array.from(nodes.values()).filter( + (node): node is Protobuf.Mesh.NodeInfo => + Boolean(node.position?.latitudeI), + ), + [nodes], + ); - const getBBox = useCallback(() => { + const handleMarkerClick = useCallback( + (node: Protobuf.Mesh.NodeInfo, event: { originalEvent: MouseEvent }) => { + event?.originalEvent?.stopPropagation(); + + setSelectedNode(node); + + if (map) { + const position = convertToLatLng(node.position); + map.easeTo({ + center: [position.longitude, position.latitude], + zoom: map?.getZoom(), + }); + } + }, + [map], + ); + + // Get the bounds of the map based on the nodes furtherest away from center + const getMapBounds = useCallback(() => { if (!map) { return; } - const nodesWithPosition = allNodes.filter( - (node) => node.position?.latitudeI, - ); - if (!nodesWithPosition.length) { + + if (!validNodes.length) { return; } - if (nodesWithPosition.length === 1) { + if (validNodes.length === 1) { map.easeTo({ - zoom: 12, + zoom: map.getZoom(), center: [ - (nodesWithPosition[0].position?.longitudeI ?? 0) / 1e7, - (nodesWithPosition[0].position?.latitudeI ?? 0) / 1e7, + (validNodes[0].position?.longitudeI ?? 0) / 1e7, + (validNodes[0].position?.latitudeI ?? 0) / 1e7, ], }); return; } const line = lineString( - nodesWithPosition.map((n) => [ + validNodes.map((n) => [ (n.position?.latitudeI ?? 0) / 1e7, (n.position?.longitudeI ?? 0) / 1e7, ]), @@ -69,78 +103,54 @@ const MapPage = (): JSX.Element => { if (center) { map.easeTo(center); } - }, [allNodes, map]); + }, [validNodes, map]); - useEffect(() => { - map?.on("zoom", () => { - setZoom(map?.getZoom() ?? 0); - }); - }, [map]); + // Generate all markers + const markers = useMemo( + () => + validNodes.map((node) => { + const position = convertToLatLng(node.position); + return ( + handleMarkerClick(node, e)} + > + + + ); + }), + [validNodes, handleMarkerClick], + ); useEffect(() => { map?.on("load", () => { - getBBox(); + getMapBounds(); }); - }, [map, getBBox]); + }, [map, getMapBounds]); return ( <> - - - {rasterSources.map((source) => ( - - ))} - - - + + { - // const waypoint = new Protobuf.Waypoint({ - // name: "test", - // description: "test description", - // latitudeI: Math.trunc(e.lngLat.lat * 1e7), - // longitudeI: Math.trunc(e.lngLat.lng * 1e7) - // }); - // addWaypoint(waypoint); - // connection?.sendWaypoint(waypoint, "broadcast"); - // }} - - // @ts-ignore - attributionControl={false} renderWorldCopies={false} maxPitch={0} + antialias={true} style={{ - filter: darkMode ? "brightness(0.8)" : "", + filter: darkMode ? "brightness(0.9)" : "", }} dragRotate={false} touchZoomRotate={false} initialViewState={{ - zoom: 1.6, + zoom: 1.8, latitude: 35, longitude: 0, }} @@ -151,6 +161,14 @@ const MapPage = (): JSX.Element => { color: darkMode ? "black" : "", }} /> + + + + {waypoints.map((wp) => ( {
    ))} - {/* {rasterSources.map((source, index) => ( - - - - ))} */} - {allNodes.map((node) => { - if (node.position?.latitudeI && node.num !== selectedNode?.num) { - return ( - { - setSelectedNode(node); - map?.easeTo({ - zoom: 12, - center: [ - (node.position?.longitudeI ?? 0) / 1e7, - (node.position?.latitudeI ?? 0) / 1e7, - ], - }); - }} - > -
    - - - {node.user?.longName || - `!${numberToHexUnpadded(node.num)}`} - -
    -
    - ); - } - })} - {selectedNode?.position && ( + {markers} + {selectedNode ? ( setSelectedNode(null)} > - )} + ) : null} diff --git a/src/pages/Messages.tsx b/src/pages/Messages.tsx index ce866ed8..2ca47ceb 100644 --- a/src/pages/Messages.tsx +++ b/src/pages/Messages.tsx @@ -1,3 +1,4 @@ +import { useAppStore } from "@app/core/stores/appStore"; import { ChannelChat } from "@components/PageComponents/Messages/ChannelChat.tsx"; import { PageLayout } from "@components/PageLayout.tsx"; import { Sidebar } from "@components/Sidebar.tsx"; @@ -5,24 +6,23 @@ import { Avatar } from "@components/UI/Avatar.tsx"; import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx"; import { SidebarButton } from "@components/UI/Sidebar/sidebarButton.tsx"; import { useToast } from "@core/hooks/useToast.ts"; -import { Device, useDevice, useDeviceStore } from "@core/stores/deviceStore.ts"; +import { useDevice } from "@core/stores/deviceStore.ts"; import { Protobuf, Types } from "@meshtastic/js"; import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; import { getChannelName } from "@pages/Channels.tsx"; import { HashIcon, LockIcon, LockOpenIcon, WaypointsIcon } from "lucide-react"; import { useState } from "react"; -const MessagesPage = () => { +export const MessagesPage = () => { const { channels, nodes, hardware, messages, traceroutes, connection } = useDevice(); - const [chatType, setChatType] = - useState("broadcast"); - const [activeChat, setActiveChat] = useState( - Types.ChannelNumber.Primary, - ); - const filteredNodes = Array.from(nodes.values()).filter( - (n) => n.num !== hardware.myNodeNum, - ); + const { activeChat, chatType, setActiveChat, setChatType } = useAppStore(); + const [searchTerm, setSearchTerm] = useState(""); + const filteredNodes = Array.from(nodes.values()).filter((node) => { + if (node.num === hardware.myNodeNum) return false; + const nodeName = node.user?.longName ?? `!${numberToHexUnpadded(node.num)}`; + return nodeName.toLowerCase().includes(searchTerm.toLowerCase()); + }); const allChannels = Array.from(channels.values()); const filteredChannels = allChannels.filter( (ch) => ch.role !== Protobuf.Channel.Channel_Role.DISABLED, @@ -56,6 +56,15 @@ const MessagesPage = () => { ))} +
    + setSearchTerm(e.target.value)} + className="w-full p-2 border border-gray-300 rounded bg-white text-black" + /> +
    {filteredNodes.map((node) => ( { }); }, }, - { - icon: WaypointsIcon, - async onClick() { - const targetNode = nodes.get(activeChat)?.num; - if (targetNode === undefined) return; - toast({ - title: "Sending Traceroute, please wait...", - }); - await connection?.traceRoute(targetNode).then(() => - toast({ - title: "Traceroute sent.", - }), - ); - }, - }, ] : [] } @@ -146,7 +140,6 @@ const MessagesPage = () => { to={activeChat} messages={messages.direct.get(node.num)} channel={Types.ChannelNumber.Primary} - traceroutes={traceroutes.get(node.num)} /> ), )} diff --git a/src/pages/Nodes.tsx b/src/pages/Nodes.tsx index 2bbcc302..6ad69f65 100644 --- a/src/pages/Nodes.tsx +++ b/src/pages/Nodes.tsx @@ -1,15 +1,18 @@ +import { LocationResponseDialog } from "@app/components/Dialog/LocationResponseDialog"; +import { NodeOptionsDialog } from "@app/components/Dialog/NodeOptionsDialog"; +import { TracerouteResponseDialog } from "@app/components/Dialog/TracerouteResponseDialog"; import Footer from "@app/components/UI/Footer"; -import { useAppStore } from "@app/core/stores/appStore"; import { Sidebar } from "@components/Sidebar.tsx"; +import { Avatar } from "@components/UI/Avatar.tsx"; import { Button } from "@components/UI/Button.tsx"; import { Mono } from "@components/generic/Mono.tsx"; import { Table } from "@components/generic/Table/index.tsx"; -import { TimeAgo } from "@components/generic/Table/tmp/TimeAgo.tsx"; +import { TimeAgo } from "@components/generic/TimeAgo.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; -import { Protobuf } from "@meshtastic/js"; +import { Protobuf, type Types } from "@meshtastic/js"; import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; -import { LockIcon, LockOpenIcon, TrashIcon } from "lucide-react"; -import { Fragment, type JSX } from "react"; +import { LockIcon, LockOpenIcon } from "lucide-react"; +import { Fragment, type JSX, useCallback, useEffect, useState } from "react"; import { base16 } from "rfc4648"; export interface DeleteNoteDialogProps { @@ -18,37 +21,103 @@ export interface DeleteNoteDialogProps { } const NodesPage = (): JSX.Element => { - const { nodes, hardware, setDialogOpen } = useDevice(); - const { setNodeNumToBeRemoved } = useAppStore(); + const { nodes, hardware, connection } = useDevice(); + const [selectedNode, setSelectedNode] = useState< + Protobuf.Mesh.NodeInfo | undefined + >(undefined); + const [selectedTraceroute, setSelectedTraceroute] = useState< + Types.PacketMetadata | undefined + >(); + const [selectedLocation, setSelectedLocation] = useState< + Types.PacketMetadata | undefined + >(); + const [searchTerm, setSearchTerm] = useState(""); - const filteredNodes = Array.from(nodes.values()).filter( - (n) => n.num !== hardware.myNodeNum, + const filteredNodes = Array.from(nodes.values()).filter((node) => { + if (node.num === hardware.myNodeNum) return false; + const nodeName = node.user?.longName ?? `!${numberToHexUnpadded(node.num)}`; + return nodeName.toLowerCase().includes(searchTerm.toLowerCase()); + }); + + 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) => { + setSelectedLocation(location); + }, + [], ); return ( <>
    +
    + setSearchTerm(e.target.value)} + className="w-full p-2 border border-gray-300 rounded bg-white text-black" + /> +
    [ - , +
    + +
    , -

    +

    setSelectedNode(node)} + className="cursor-pointer" + > + {node.user?.shortName ?? + (node.user?.macaddr + ? `${base16 + .stringify(node.user?.macaddr.subarray(4, 6) ?? []) + .toLowerCase()}` + : `${numberToHexUnpadded(node.num).slice(-4)}`)} +

    , + +

    setSelectedNode(node)} + className="cursor-pointer" + > {node.user?.longName ?? (node.user?.macaddr ? `Meshtastic ${base16 @@ -82,7 +151,7 @@ const NodesPage = (): JSX.Element => { {node.user?.publicKey && node.user?.publicKey.length > 0 ? ( ) : ( - + )} , @@ -95,19 +164,23 @@ const NodesPage = (): JSX.Element => { : "-"} {node.viaMqtt === true ? ", via MQTT" : ""} , - , ])} /> + setSelectedNode(undefined)} + /> + setSelectedTraceroute(undefined)} + /> + setSelectedLocation(undefined)} + />