diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml new file mode 100644 index 00000000..ec8efd55 --- /dev/null +++ b/.github/workflows/format.yml @@ -0,0 +1,48 @@ +name: Code Formatting + +on: + pull_request: + branches: [ main, master ] + workflow_dispatch: + +jobs: + format: + runs-on: ubuntu-latest + + permissions: + contents: write + pull-requests: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref }} + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install Biome + run: npm install --global @biomejs/biome + + - name: Format with Biome + run: biome format --write . + + - name: Check for changes + id: git-check + run: | + git diff --quiet || echo "changes=true" >> $GITHUB_OUTPUT + + - name: Commit and push changes + if: steps.git-check.outputs.changes == 'true' + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + + git add -A + git commit -m "chore: format code" + git push \ No newline at end of file diff --git a/package.json b/package.json index 22cee204..e9735374 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,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" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ac5d3d93..cd36de64 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.34.6)(vite@6.1.0(@types/node@22.12.0)(jiti@1.21.6)(yaml@2.4.5)) @@ -2860,14 +2857,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'} @@ -6629,13 +6618,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/PageComponents/Connect/HTTP.tsx b/src/components/PageComponents/Connect/HTTP.tsx index de776327..79a3b6c1 100644 --- a/src/components/PageComponents/Connect/HTTP.tsx +++ b/src/components/PageComponents/Connect/HTTP.tsx @@ -23,18 +23,13 @@ export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => { window.location.hostname, ) ? "meshtastic.local" - : window.location.hostname, + : window.location.host, tls: location.protocol === "https:", }, }); - const tlsEnabled = useWatch({ - control, - name: "tls", - defaultValue: location.protocol === "https:", - }); - const [connectionInProgress, setConnectionInProgress] = useState(false); + const [https, setHTTPS] = useState(false); const onSubmit = handleSubmit(async (data) => { setConnectionInProgress(true); @@ -46,7 +41,7 @@ export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => { await connection.connect({ address: data.ip, fetchInterval: 2000, - tls: data.tls, + tls: https, }); setSelectedDevice(id); @@ -60,8 +55,7 @@ export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => {
{ control={control} render={({ field: { value, ...rest } }) => ( <> - + { + checked ? setHTTPS(true) : setHTTPS(false); + }} + // label="Use TLS" // description="Description" disabled={ 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 8fb8365f..cdbca329 100644 --- a/src/components/PageComponents/Messages/ChannelChat.tsx +++ b/src/components/PageComponents/Messages/ChannelChat.tsx @@ -51,11 +51,11 @@ export const ChannelChat = ({ if (!messages?.length) { return ( -
+
-
+
@@ -63,23 +63,25 @@ export const ChannelChat = ({ } return ( -
-
-
- {messages.map((message, index) => ( - 0 && messages[index - 1].from === message.from - } - sender={nodes.get(message.from)} - /> - ))} +
+
+
+ {messages.map((message, index) => { + return ( + 0 && messages[index - 1].from === message.from + } + /> + ); + })}
-
+
diff --git a/src/components/PageComponents/Messages/Message.tsx b/src/components/PageComponents/Messages/Message.tsx index 66268219..a6eb8f9a 100644 --- a/src/components/PageComponents/Messages/Message.tsx +++ b/src/components/PageComponents/Messages/Message.tsx @@ -1,10 +1,21 @@ -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 * as Tooltip from "@radix-ui/react-tooltip"; import { AlertCircle, CheckCircle2, CircleEllipsis } from "lucide-react"; import type { LucideIcon } from "lucide-react"; +import { useMemo } from "react"; const MESSAGE_STATES = { ACK: "ack", @@ -17,7 +28,7 @@ type MessageState = MessageWithState["state"]; interface MessageProps { lastMsgSameUser: boolean; message: MessageWithState; - sender?: Protobuf.Mesh.NodeInfo; + sender: Protobuf.Mesh.NodeInfo; } interface StatusTooltipProps { @@ -45,22 +56,20 @@ const STATUS_ICON_MAP: Record = { const getStatusText = (state: MessageState): string => STATUS_TEXT_MAP[state]; const StatusTooltip = ({ state, children }: StatusTooltipProps) => ( - - - {children} - - - {getStatusText(state)} - - - - - + + + {children} + + {getStatusText(state)} + + + + ); const StatusIcon = ({ state, className, ...otherProps }: StatusIconProps) => { @@ -88,7 +97,7 @@ const getMessageTextStyles = (state: MessageState) => { const isWaiting = state === MESSAGE_STATES.WAITING; return cn( - "pl-2 break-words overflow-hidden", + "break-words overflow-hidden", isAcknowledged ? "text-black dark:text-white" : "text-black dark:text-gray-400", @@ -96,8 +105,11 @@ const getMessageTextStyles = (state: MessageState) => { ); }; -const TimeDisplay = ({ date }: { date: Date }) => ( -
+const TimeDisplay = ({ + date, + className, +}: { date: Date; className?: string }) => ( +
{date.toLocaleDateString()} @@ -111,44 +123,46 @@ const TimeDisplay = ({ date }: { date: Date }) => ( ); export const Message = ({ lastMsgSameUser, message, sender }: MessageProps) => { - const messageTextClass = getMessageTextStyles(message.state); - const isFailed = message.state === MESSAGE_STATES.ACK; - - const baseMessageWrapper = cn( - "flex items-center gap-2 w-full max-w-full pl-11", - !lastMsgSameUser && "flex-wrap flex-grow", + 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 containerClass = cn( - "w-full px-4 relative", - lastMsgSameUser ? "mt-1" : "mt-2", - !lastMsgSameUser && "pt-2", - ); + const messageTextClass = getMessageTextStyles(message.state); return ( -
- {!lastMsgSameUser && ( -
-
- - - {sender?.user?.longName ?? "UNK"} - -
- +
+
+
+ {!lastMsgSameUser ? ( +
+ +
+ + {messageUser?.longName} + +
+
+ ) : null}
- )} -
-
-
{message.data}
+ +
+
+ {message.data} +
+
-
); diff --git a/src/components/PageComponents/Messages/MessageInput.tsx b/src/components/PageComponents/Messages/MessageInput.tsx index 2e7954c9..e8b85357 100644 --- a/src/components/PageComponents/Messages/MessageInput.tsx +++ b/src/components/PageComponents/Messages/MessageInput.tsx @@ -4,7 +4,13 @@ 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 { type JSX, useCallback, useMemo, useState } from "react"; +import { + type JSX, + startTransition, + useCallback, + useMemo, + useState, +} from "react"; export interface MessageInputProps { to: Types.Destination; @@ -26,7 +32,7 @@ export const MessageInput = ({ } = useDevice(); const myNodeNum = hardware.myNodeNum; const [localDraft, setLocalDraft] = useState(messageDraft); - const [messageBytes, setMessageBytes] = useState(maxBytes); + const [messageBytes, setMessageBytes] = useState(0); const debouncedSetMessageDraft = useMemo( () => debounce(setMessageDraft, 300), @@ -64,10 +70,11 @@ export const MessageInput = ({ const handleInputChange = (e: React.ChangeEvent) => { const newValue = e.target.value; const byteLength = new Blob([newValue]).size; + if (byteLength <= maxBytes) { setLocalDraft(newValue); debouncedSetMessageDraft(newValue); - setMessageBytes(maxBytes - byteLength); + setMessageBytes(byteLength); } }; @@ -75,11 +82,16 @@ export const MessageInput = ({
{ - 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); + }); }} >
@@ -87,14 +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/UI/Accordion.tsx b/src/components/UI/Accordion.tsx new file mode 100644 index 00000000..a44017b1 --- /dev/null +++ b/src/components/UI/Accordion.tsx @@ -0,0 +1,44 @@ +import { cn } from "@core/utils/cn.ts"; +import * as AccordionPrimitive from "@radix-ui/react-accordion"; +import { ChevronDownIcon } from "lucide-react"; +import { type ComponentRef, forwardRef } from "react"; + +export const Accordion = AccordionPrimitive.Root; + +export const AccordionHeader = AccordionPrimitive.Header; + +export const AccordionItem = AccordionPrimitive.Item; + +export const AccordionTrigger = forwardRef< + ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + {props.children} + + +)); + +export const AccordionContent = forwardRef< + ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); diff --git a/src/components/UI/Button.tsx b/src/components/UI/Button.tsx index 18861065..1c332689 100644 --- a/src/components/UI/Button.tsx +++ b/src/components/UI/Button.tsx @@ -15,7 +15,7 @@ const buttonVariants = cva( success: "bg-green-500 text-white hover:bg-green-600 dark:hover:bg-green-600", outline: - "bg-transparent border border-slate-200 hover:bg-slate-100 dark:border-slate-700 dark:text-slate-100", + "bg-transparent border border-slate-200 hover:bg-slate-100 dark:border-slate-400 dark:text-slate-100", subtle: "bg-slate-100 text-slate-900 hover:bg-slate-200 dark:bg-slate-700 dark:text-slate-100", ghost: diff --git a/src/components/UI/Dialog.tsx b/src/components/UI/Dialog.tsx index 019b8142..d989b842 100644 --- a/src/components/UI/Dialog.tsx +++ b/src/components/UI/Dialog.tsx @@ -44,7 +44,7 @@ const DialogContent = React.forwardRef< ; 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/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/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 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], + ); - const getBBox = useCallback(() => { + // 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 02ce37c6..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,21 +6,17 @@ 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 { activeChat, chatType, setActiveChat, setChatType } = useAppStore(); const [searchTerm, setSearchTerm] = useState(""); const filteredNodes = Array.from(nodes.values()).filter((node) => { if (node.num === hardware.myNodeNum) return false; diff --git a/src/pages/Nodes.tsx b/src/pages/Nodes.tsx index 93d12b7e..6ad69f65 100644 --- a/src/pages/Nodes.tsx +++ b/src/pages/Nodes.tsx @@ -1,16 +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 { useState } from "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 { @@ -19,8 +21,16 @@ 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((node) => { @@ -29,6 +39,36 @@ const NodesPage = (): JSX.Element => { 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 ( <> @@ -46,22 +86,38 @@ const NodesPage = (): JSX.Element => { [ - , +
+ +
, -

+

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 @@ -95,7 +151,7 @@ const NodesPage = (): JSX.Element => { {node.user?.publicKey && node.user?.publicKey.length > 0 ? ( ) : ( - + )} , @@ -108,19 +164,23 @@ const NodesPage = (): JSX.Element => { : "-"} {node.viaMqtt === true ? ", via MQTT" : ""} , - , ])} /> + setSelectedNode(undefined)} + /> + setSelectedTraceroute(undefined)} + /> + setSelectedLocation(undefined)} + />