Browse Source

Favourites WIP

pull/618/head
philon- 1 year ago
parent
commit
4ab41b7507
  1. 287
      src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx
  2. 14
      src/core/stores/deviceStore.ts
  3. 4
      src/pages/Nodes.tsx

287
src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx

@ -1,5 +1,18 @@
import { useEffect, useState } from "react";
import { useAppStore } from "@core/stores/appStore.ts"; import { useAppStore } from "@core/stores/appStore.ts";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import {
MessageType,
useMessageStore,
} from "@core/stores/messageStore/index.ts";
import { Protobuf } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { DeviceImage } from "@components/generic/DeviceImage.tsx";
import { TimeAgo } from "@components/generic/TimeAgo.tsx";
import { Uptime } from "@components/generic/Uptime.tsx";
import { toast } from "@core/hooks/useToast.ts";
import { import {
Accordion, Accordion,
AccordionContent, AccordionContent,
@ -14,51 +27,122 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@components/UI/Dialog.tsx"; } from "@components/UI/Dialog.tsx";
import { Protobuf } from "@meshtastic/core"; import { Button } from "@components/UI/Button.tsx";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { DeviceImage } from "@components/generic/DeviceImage.tsx"; import {
import { TimeAgo } from "@components/generic/TimeAgo.tsx"; EarIcon,
import { Uptime } from "@components/generic/Uptime.tsx"; EarOffIcon,
MapPinnedIcon,
MessageSquareIcon,
StarIcon,
TrashIcon,
WaypointsIcon,
} from "lucide-react";
import {
Tooltip,
TooltipArrow,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@components/UI/Tooltip.tsx";
import { Separator } from "@components/UI/Seperator.tsx";
export interface NodeDetailsDialogProps { export interface NodeDetailsDialogProps {
node: Protobuf.Mesh.NodeInfo | undefined;
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
} }
export const NodeDetailsDialog = ({ export const NodeDetailsDialog = ({
node,
open, open,
onOpenChange, onOpenChange,
}: NodeDetailsDialogProps) => { }: NodeDetailsDialogProps) => {
const { getNode } = useDevice(); const { setDialogOpen, connection, setActivePage, setFavorite } = useDevice();
const { nodeNumDetails } = useAppStore(); const { setNodeNumToBeRemoved } = useAppStore();
const { setChatType, setActiveChat } = useMessageStore();
const [isFavouriteState, setIsFavoriteState] = useState<boolean>(false);
useEffect(() => {
if (!node) return;
setIsFavoriteState(node?.isFavorite);
}, [node]);
if (!node) return null;
function handleDirectMessage() {
if (!node) return null;
setChatType(MessageType.Direct);
setActiveChat(node.num);
setActivePage("messages");
}
function handleRequestPosition() {
if (!node) return null;
toast({
title: "Requesting position, please wait...",
});
connection?.requestPosition(node.num).then(() =>
toast({
title: "Position request sent.",
})
);
onOpenChange(false);
}
function handleTraceroute() {
if (!node) return null;
const device = getNode(nodeNumDetails); toast({
title: "Sending Traceroute, please wait...",
});
connection?.traceRoute(node.num).then(() =>
toast({
title: "Traceroute sent.",
})
);
onOpenChange(false);
}
if (!device) return null; function handleNodeRemove() {
if (!node) return null;
setNodeNumToBeRemoved(node?.num);
setDialogOpen("nodeRemoval", true);
onOpenChange(false);
}
function handleToggleFavourite() {
if (!node) return null;
setFavorite(node.num, !isFavouriteState);
setIsFavoriteState(!isFavouriteState);
}
const deviceMetricsMap = [ const deviceMetricsMap = [
{ {
key: "airUtilTx", key: "airUtilTx",
label: "Air TX utilization", label: "Air TX utilization",
value: device.deviceMetrics?.airUtilTx, value: node.deviceMetrics?.airUtilTx,
format: (val: number) => `${val.toFixed(2)}%`, format: (val: number) => `${val.toFixed(2)}%`,
}, },
{ {
key: "channelUtilization", key: "channelUtilization",
label: "Channel utilization", label: "Channel utilization",
value: device.deviceMetrics?.channelUtilization, value: node.deviceMetrics?.channelUtilization,
format: (val: number) => `${val.toFixed(2)}%`, format: (val: number) => `${val.toFixed(2)}%`,
}, },
{ {
key: "batteryLevel", key: "batteryLevel",
label: "Battery level", label: "Battery level",
value: device.deviceMetrics?.batteryLevel, value: node.deviceMetrics?.batteryLevel,
format: (val: number) => `${val.toFixed(2)}%`, format: (val: number) => `${val.toFixed(2)}%`,
}, },
{ {
key: "voltage", key: "voltage",
label: "Voltage", label: "Voltage",
value: device.deviceMetrics?.voltage, value: node.deviceMetrics?.voltage,
format: (val: number) => `${val.toFixed(2)}V`, format: (val: number) => `${val.toFixed(2)}V`,
}, },
]; ];
@ -69,64 +153,133 @@ export const NodeDetailsDialog = ({
<DialogClose /> <DialogClose />
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
Node Details for {device.user?.longName ?? "UNKNOWN"} ( Node Details for {node.user?.longName ?? "UNKNOWN"} (
{device.user?.shortName ?? "UNK"}) {node.user?.shortName ?? "UNK"})
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<div className="w-full"> <div className="w-full">
<div className="flex flex-col"> <div className="flex flex-row flex-wrap space-y-1">
<DeviceImage <Button className="mr-1" onClick={handleDirectMessage}>
className="w-32 h-32 mx-auto rounded-lg border-4 border-slate-200 dark:border-slate-800" <MessageSquareIcon className="mr-2" />
deviceType={Protobuf.Mesh Message
.HardwareModel[device.user?.hwModel ?? 0]} </Button>
/> <Button className="mr-1" onClick={handleTraceroute}>
<div className="bg-slate-100 text-slate-900 dark:text-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3"> <WaypointsIcon className="mr-2" />
<p className="text-lg font-semibold">Details:</p> Trace Route
<p> </Button>
Hardware:{" "} <Button className="mr-1" onClick={handleToggleFavourite}>
{Protobuf.Mesh.HardwareModel[device.user?.hwModel ?? 0]} <StarIcon
</p> className={isFavouriteState ? " fill-yellow-400" : ""}
<p>Node Number: {device.num}</p> />
<p>Node Hex: !{numberToHexUnpadded(device.num)}</p> </Button>
<p> <div className="flex flex-1 justify-start"></div>
Role: {Protobuf.Config.Config_DeviceConfig_Role[
device.user?.role ?? 0
]}
</p>
<p>
Last Heard: {device.lastHeard === 0
? "Never"
: <TimeAgo timestamp={device.lastHeard * 1000} />}
</p>
</div>
{device.position && ( <TooltipProvider delayDuration={300}>
<div className="text-slate-900 dark:text-slate-100 bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3"> <Tooltip>
<p className="text-lg font-semibold">Position:</p> <TooltipTrigger asChild>
{device.position.latitudeI && device.position.longitudeI && ( <Button
<p> variant={node.isIgnored ? "default" : "destructive"}
Coordinates:{" "} className="flex justify-end mr-1"
<a >
className="text-blue-500 dark:text-blue-400" {node.isIgnored ? <EarIcon /> : <EarOffIcon />}
href={`https://www.openstreetmap.org/?mlat=${ </Button>
device.position.latitudeI / 1e7 </TooltipTrigger>
}&mlon=${device.position.longitudeI / 1e7}&layers=N`} <TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs">
target="_blank" {node.isIgnored ? "Unignore node" : "Ignore node"}
rel="noreferrer" <TooltipArrow className="fill-slate-800 dark:fill-slate-600" />
> </TooltipContent>
{device.position.latitudeI / 1e7},{" "} </Tooltip>
{device.position.longitudeI / 1e7} </TooltipProvider>
</a>
</p> <TooltipProvider delayDuration={300}>
)} <Tooltip>
{device.position.altitude && ( <TooltipTrigger asChild>
<p>Altitude: {device.position.altitude}m</p> <Button
)} variant="destructive"
className="flex justify-end"
onClick={handleNodeRemove}
>
<TrashIcon />
</Button>
</TooltipTrigger>
<TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs">
Remove node
<TooltipArrow className="fill-slate-800 dark:fill-slate-600" />
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<Separator className="mt-5 mb-2" />
<div className="flex flex-col flex-wrap space-x-1 space-y-1">
<div className="flex flex-row space-x-2">
<div className="w-full bg-slate-100 text-slate-900 dark:text-slate-100 dark:bg-slate-800 p-3 rounded-lg">
<p className="text-lg font-semibold">Details:</p>
<p>Node Number: {node.num}</p>
<p>Node Hex: !{numberToHexUnpadded(node.num)}</p>
<p>
Role: {Protobuf.Config.Config_DeviceConfig_Role[
node.user?.role ?? 0
].replace(/_/g, " ")}
</p>
<p>
Last Heard: {node.lastHeard === 0
? "Never"
: <TimeAgo timestamp={node.lastHeard * 1000} />}
</p>
<p>
Hardware:{" "}
{Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]
.replace(/_/g, " ")}
</p>
</div> </div>
)} <DeviceImage
className="h-45 w-45 p-2 rounded-lg border-4 border-slate-200 dark:border-slate-800"
deviceType={Protobuf.Mesh
.HardwareModel[node.user?.hwModel ?? 0]}
/>
</div>
</div>
<div>
<div className="text-slate-900 dark:text-slate-100 bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
<p className="text-lg font-semibold">Position:</p>
{node.position
? (
<>
{node.position.latitudeI &&
node.position.longitudeI && (
<p>
Coordinates:{" "}
<a
className="text-blue-500 dark:text-blue-400"
href={`https://www.openstreetmap.org/?mlat=${
node.position.latitudeI / 1e7
}&mlon=${node.position.longitudeI / 1e7}&layers=N`}
target="_blank"
rel="noreferrer"
>
{node.position.latitudeI / 1e7},{" "}
{node.position.longitudeI / 1e7}
</a>
</p>
)}
{node.position.altitude && (
<p>Altitude: {node.position.altitude}m</p>
)}
</>
)
: <p>Unknown</p>}
<Button onClick={handleRequestPosition}>
<MapPinnedIcon className="mr-2" />
Request Position
</Button>
</div>
{device.deviceMetrics && ( {node.deviceMetrics && (
<div className="text-slate-900 dark:text-slate-100 bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3"> <div className="text-slate-900 dark:text-slate-100 bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
<p className="text-lg font-semibold text-slate-900 dark:text-slate-100"> <p className="text-lg font-semibold text-slate-900 dark:text-slate-100">
Device Metrics: Device Metrics:
@ -139,10 +292,10 @@ export const NodeDetailsDialog = ({
</p> </p>
), ),
)} )}
{device.deviceMetrics.uptimeSeconds && ( {node.deviceMetrics.uptimeSeconds && (
<p> <p>
Uptime:{" "} Uptime:{" "}
<Uptime seconds={device.deviceMetrics.uptimeSeconds} /> <Uptime seconds={node.deviceMetrics.uptimeSeconds} />
</p> </p>
)} )}
</div> </div>
@ -159,7 +312,7 @@ export const NodeDetailsDialog = ({
</AccordionTrigger> </AccordionTrigger>
<AccordionContent className="overflow-x-scroll"> <AccordionContent className="overflow-x-scroll">
<pre className="text-xs w-full"> <pre className="text-xs w-full">
{JSON.stringify(device, null, 2)} {JSON.stringify(node, null, 2)}
</pre> </pre>
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>

14
src/core/stores/deviceStore.ts

@ -93,6 +93,7 @@ export interface Device {
getNodesLength: () => number; getNodesLength: () => number;
getNode: (nodeNum: number) => Protobuf.Mesh.NodeInfo | undefined; getNode: (nodeNum: number) => Protobuf.Mesh.NodeInfo | undefined;
getMyNode: () => Protobuf.Mesh.NodeInfo; getMyNode: () => Protobuf.Mesh.NodeInfo;
setFavorite: (nodeNum: number, isFavorite: boolean) => void;
} }
export interface DeviceState { export interface DeviceState {
@ -594,6 +595,19 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
} }
return device.nodesMap.size; return device.nodesMap.size;
}, },
setFavorite(nodeNum: number, isFavorite: boolean) {
set(
produce<DeviceState>((draft) => {
const device = draft.devices.get(id);
if (!device) throw new Error(`Device ${id} not found`);
const node = device.nodesMap.get(nodeNum);
if (!node) throw new Error(`Node ${nodeNum} not found`);
node.isFavorite = isFavorite;
}),
);
},
}); });
}), }),
); );

4
src/pages/Nodes.tsx

@ -1,5 +1,5 @@
import { LocationResponseDialog } from "@app/components/Dialog/LocationResponseDialog.tsx"; import { LocationResponseDialog } from "@app/components/Dialog/LocationResponseDialog.tsx";
import { NodeOptionsDialog } from "@app/components/Dialog/NodeOptionsDialog.tsx"; import { NodeDetailsDialog } from "@app/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx";
import { TracerouteResponseDialog } from "@app/components/Dialog/TracerouteResponseDialog.tsx"; import { TracerouteResponseDialog } from "@app/components/Dialog/TracerouteResponseDialog.tsx";
import { Sidebar } from "@components/Sidebar.tsx"; import { Sidebar } from "@components/Sidebar.tsx";
import { Avatar } from "@components/UI/Avatar.tsx"; import { Avatar } from "@components/UI/Avatar.tsx";
@ -164,7 +164,7 @@ const NodesPage = (): JSX.Element => {
</Mono>, </Mono>,
])} ])}
/> />
<NodeOptionsDialog <NodeDetailsDialog
node={selectedNode} node={selectedNode}
open={!!selectedNode} open={!!selectedNode}
onOpenChange={() => setSelectedNode(undefined)} onOpenChange={() => setSelectedNode(undefined)}

Loading…
Cancel
Save