@@ -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
;
+};
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)}
+ />