Feature: Node favourites (and ignores) (#618)
* Add favourite icon to node Avatar * Favourites WIP * Save isFavorite and isIgnored to device * Fix spelling * Clean up * Always sort favorites first * Add unread count to "Messages" top level menu * Renaming, UI tweaks * Add hook tests * Handle undefined node better
This commit is contained in:
@@ -1,18 +1,22 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx";
|
import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
|
||||||
import { useAppStore } from "@core/stores/appStore.ts";
|
import { useAppStore } from "@core/stores/appStore.ts";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
|
|
||||||
vi.mock("@core/stores/deviceStore");
|
vi.mock("@core/stores/deviceStore", () => {
|
||||||
|
return {
|
||||||
|
useDevice: () => ({
|
||||||
|
setDialogOpen: vi.fn(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
vi.mock("@core/stores/appStore");
|
vi.mock("@core/stores/appStore");
|
||||||
|
|
||||||
const mockUseDevice = vi.mocked(useDevice);
|
|
||||||
const mockUseAppStore = vi.mocked(useAppStore);
|
const mockUseAppStore = vi.mocked(useAppStore);
|
||||||
|
|
||||||
describe("NodeDetailsDialog", () => {
|
describe("NodeDetailsDialog", () => {
|
||||||
const mockDevice = {
|
const mockNode = {
|
||||||
num: 1234,
|
num: 1234,
|
||||||
user: {
|
user: {
|
||||||
longName: "Test Node",
|
longName: "Test Node",
|
||||||
@@ -38,22 +42,13 @@ describe("NodeDetailsDialog", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
|
|
||||||
mockUseDevice.mockReturnValue({
|
|
||||||
getNode: (nodeNum: number) => {
|
|
||||||
if (nodeNum === 1234) {
|
|
||||||
return mockDevice;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
mockUseAppStore.mockReturnValue({
|
mockUseAppStore.mockReturnValue({
|
||||||
nodeNumDetails: 1234,
|
nodeNumDetails: 1234,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders node details correctly", () => {
|
it("renders node details correctly", () => {
|
||||||
render(<NodeDetailsDialog open onOpenChange={() => {}} />);
|
render(<NodeDetailsDialog open node={mockNode} onOpenChange={() => {}} />);
|
||||||
|
|
||||||
expect(screen.getByText(/Node Details for Test Node \(TN\)/i))
|
expect(screen.getByText(/Node Details for Test Node \(TN\)/i))
|
||||||
.toBeInTheDocument();
|
.toBeInTheDocument();
|
||||||
@@ -82,27 +77,11 @@ describe("NodeDetailsDialog", () => {
|
|||||||
expect(screen.getByText(/All Raw Metrics:/i)).toBeInTheDocument();
|
expect(screen.getByText(/All Raw Metrics:/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders null if device is not found", () => {
|
it("renders null if node is undefined", () => {
|
||||||
const requestedNodeNum = 5678;
|
const mockNode = undefined;
|
||||||
|
|
||||||
mockUseAppStore.mockReturnValue({
|
|
||||||
nodeNumDetails: requestedNodeNum,
|
|
||||||
});
|
|
||||||
|
|
||||||
mockUseDevice.mockReturnValue({
|
|
||||||
getNode: (nodeNum: number) => {
|
|
||||||
if (nodeNum === requestedNodeNum) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
if (nodeNum === 1234) {
|
|
||||||
return mockDevice;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<NodeDetailsDialog open onOpenChange={() => {}} />,
|
<NodeDetailsDialog open node={mockNode} onOpenChange={() => {}} />,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(container.firstChild).toBeNull();
|
expect(container.firstChild).toBeNull();
|
||||||
@@ -110,11 +89,15 @@ describe("NodeDetailsDialog", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("renders correctly when position is missing", () => {
|
it("renders correctly when position is missing", () => {
|
||||||
const nodeWithoutPosition = { ...mockDevice, position: undefined };
|
const nodeWithoutPosition = { ...mockNode, position: undefined };
|
||||||
mockUseDevice.mockReturnValue({ getNode: () => nodeWithoutPosition });
|
|
||||||
mockUseAppStore.mockReturnValue({ nodeNumDetails: 1234 });
|
|
||||||
|
|
||||||
render(<NodeDetailsDialog open onOpenChange={() => {}} />);
|
render(
|
||||||
|
<NodeDetailsDialog
|
||||||
|
open
|
||||||
|
node={nodeWithoutPosition}
|
||||||
|
onOpenChange={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
expect(screen.queryByText(/Coordinates:/i)).not.toBeInTheDocument();
|
expect(screen.queryByText(/Coordinates:/i)).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText(/Altitude:/i)).not.toBeInTheDocument();
|
expect(screen.queryByText(/Altitude:/i)).not.toBeInTheDocument();
|
||||||
@@ -122,11 +105,15 @@ describe("NodeDetailsDialog", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("renders correctly when deviceMetrics are missing", () => {
|
it("renders correctly when deviceMetrics are missing", () => {
|
||||||
const nodeWithoutMetrics = { ...mockDevice, deviceMetrics: undefined };
|
const nodeWithoutMetrics = { ...mockNode, deviceMetrics: undefined };
|
||||||
mockUseDevice.mockReturnValue({ getNode: () => nodeWithoutMetrics });
|
|
||||||
mockUseAppStore.mockReturnValue({ nodeNumDetails: 1234 });
|
|
||||||
|
|
||||||
render(<NodeDetailsDialog open onOpenChange={() => {}} />);
|
render(
|
||||||
|
<NodeDetailsDialog
|
||||||
|
open
|
||||||
|
node={nodeWithoutMetrics}
|
||||||
|
onOpenChange={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
expect(screen.queryByText(/Device Metrics:/i)).not.toBeInTheDocument();
|
expect(screen.queryByText(/Device Metrics:/i)).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText(/Air TX utilization:/i)).not.toBeInTheDocument();
|
expect(screen.queryByText(/Air TX utilization:/i)).not.toBeInTheDocument();
|
||||||
@@ -134,11 +121,11 @@ describe("NodeDetailsDialog", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("renders 'Never' for lastHeard when timestamp is 0", () => {
|
it("renders 'Never' for lastHeard when timestamp is 0", () => {
|
||||||
const nodeNeverHeard = { ...mockDevice, lastHeard: 0 };
|
const nodeNeverHeard = { ...mockNode, lastHeard: 0 };
|
||||||
mockUseDevice.mockReturnValue({ getNode: () => nodeNeverHeard });
|
|
||||||
mockUseAppStore.mockReturnValue({ nodeNumDetails: 1234 });
|
|
||||||
|
|
||||||
render(<NodeDetailsDialog open onOpenChange={() => {}} />);
|
render(
|
||||||
|
<NodeDetailsDialog open node={nodeNeverHeard} onOpenChange={() => {}} />,
|
||||||
|
);
|
||||||
|
|
||||||
expect(screen.getByText(/Last Heard: Never/i)).toBeInTheDocument();
|
expect(screen.getByText(/Last Heard: Never/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
|
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 { useFavoriteNode } from "../../../core/hooks/useFavoriteNode.ts";
|
||||||
|
import { useIgnoreNode } from "../../../core/hooks/useIgnoreNode.ts";
|
||||||
|
import { cn } from "@core/utils/cn.ts";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
AccordionContent,
|
AccordionContent,
|
||||||
@@ -14,51 +30,135 @@ 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 {
|
||||||
import { DeviceImage } from "@components/generic/DeviceImage.tsx";
|
BellIcon,
|
||||||
import { TimeAgo } from "@components/generic/TimeAgo.tsx";
|
BellOffIcon,
|
||||||
import { Uptime } from "@components/generic/Uptime.tsx";
|
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 } = useDevice();
|
||||||
const { nodeNumDetails } = useAppStore();
|
const { setNodeNumToBeRemoved } = useAppStore();
|
||||||
|
const { setChatType, setActiveChat } = useMessageStore();
|
||||||
|
|
||||||
const device = getNode(nodeNumDetails);
|
const { updateFavorite } = useFavoriteNode();
|
||||||
|
const [isFavoriteState, setIsFavoriteState] = useState<boolean>(false);
|
||||||
|
|
||||||
if (!device) return null;
|
const { updateIgnored } = useIgnoreNode();
|
||||||
|
const [isIgnoredState, setIsIgnoredState] = useState<boolean>(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!node) return;
|
||||||
|
setIsFavoriteState(node?.isFavorite);
|
||||||
|
setIsIgnoredState(node?.isIgnored);
|
||||||
|
}, [node]);
|
||||||
|
|
||||||
|
if (!node) return;
|
||||||
|
|
||||||
|
function handleDirectMessage() {
|
||||||
|
if (!node) return;
|
||||||
|
|
||||||
|
setChatType(MessageType.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(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTraceroute() {
|
||||||
|
if (!node) return;
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Sending Traceroute, please wait...",
|
||||||
|
});
|
||||||
|
connection?.traceRoute(node.num).then(() =>
|
||||||
|
toast({
|
||||||
|
title: "Traceroute sent.",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
onOpenChange(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNodeRemove() {
|
||||||
|
if (!node) return;
|
||||||
|
|
||||||
|
setNodeNumToBeRemoved(node?.num);
|
||||||
|
setDialogOpen("nodeRemoval", true);
|
||||||
|
onOpenChange(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleToggleFavorite() {
|
||||||
|
if (!node) return;
|
||||||
|
|
||||||
|
updateFavorite({ nodeNum: node.num, isFavorite: !isFavoriteState });
|
||||||
|
setIsFavoriteState(!isFavoriteState);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleToggleIgnored() {
|
||||||
|
if (!node) return;
|
||||||
|
|
||||||
|
updateIgnored({ nodeNum: node.num, isIgnored: !isIgnoredState });
|
||||||
|
setIsIgnoredState(!isIgnoredState);
|
||||||
|
}
|
||||||
|
|
||||||
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 +169,141 @@ 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={handleToggleFavorite}>
|
||||||
{Protobuf.Mesh.HardwareModel[device.user?.hwModel ?? 0]}
|
<StarIcon
|
||||||
</p>
|
className={cn(
|
||||||
<p>Node Number: {device.num}</p>
|
isFavoriteState ? " fill-yellow-400 stroke-yellow-400" : "",
|
||||||
<p>Node Hex: !{numberToHexUnpadded(device.num)}</p>
|
)}
|
||||||
<p>
|
/>
|
||||||
Role: {Protobuf.Config.Config_DeviceConfig_Role[
|
</Button>
|
||||||
device.user?.role ?? 0
|
<div className="flex flex-1 justify-start"></div>
|
||||||
]}
|
|
||||||
</p>
|
<TooltipProvider delayDuration={300}>
|
||||||
<p>
|
<Tooltip>
|
||||||
Last Heard: {device.lastHeard === 0
|
<TooltipTrigger asChild>
|
||||||
? "Never"
|
<Button
|
||||||
: <TimeAgo timestamp={device.lastHeard * 1000} />}
|
className={cn(
|
||||||
</p>
|
"flex justify-end mr-1 text-white",
|
||||||
|
isIgnoredState
|
||||||
|
? "bg-red-500 dark:bg-red-500 hover:bg-red-600 hover:dark:bg-red-600 text-white dark:text-white"
|
||||||
|
: "",
|
||||||
|
)}
|
||||||
|
onClick={handleToggleIgnored}
|
||||||
|
>
|
||||||
|
{isIgnoredState ? <BellIcon /> : <BellOffIcon />}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs">
|
||||||
|
{isIgnoredState ? "Unignore node" : "Ignore node"}
|
||||||
|
<TooltipArrow className="fill-slate-800 dark:fill-slate-600" />
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
|
<TooltipProvider delayDuration={300}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<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] ??
|
||||||
|
"Unknown")
|
||||||
|
.replace(/_/g, " ")}
|
||||||
|
</p>
|
||||||
|
</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} className="mt-2">
|
||||||
|
<MapPinnedIcon className="mr-2" />
|
||||||
|
Request Position
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{device.position && (
|
{node.deviceMetrics && (
|
||||||
<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>
|
|
||||||
{device.position.latitudeI && device.position.longitudeI && (
|
|
||||||
<p>
|
|
||||||
Coordinates:{" "}
|
|
||||||
<a
|
|
||||||
className="text-blue-500 dark:text-blue-400"
|
|
||||||
href={`https://www.openstreetmap.org/?mlat=${
|
|
||||||
device.position.latitudeI / 1e7
|
|
||||||
}&mlon=${device.position.longitudeI / 1e7}&layers=N`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
{device.position.latitudeI / 1e7},{" "}
|
|
||||||
{device.position.longitudeI / 1e7}
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{device.position.altitude && (
|
|
||||||
<p>Altitude: {device.position.altitude}m</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{device.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 +316,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 +336,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>
|
||||||
|
|||||||
@@ -1,120 +0,0 @@
|
|||||||
import { toast } from "../../core/hooks/useToast.ts";
|
|
||||||
import { useAppStore } from "../../core/stores/appStore.ts";
|
|
||||||
import { useDevice } from "../../core/stores/deviceStore.ts";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogClose,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "../UI/Dialog.tsx";
|
|
||||||
import type { Protobuf } from "@meshtastic/core";
|
|
||||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
|
||||||
import { TrashIcon } from "lucide-react";
|
|
||||||
|
|
||||||
import { Button } from "../UI/Button.tsx";
|
|
||||||
import {
|
|
||||||
MessageType,
|
|
||||||
useMessageStore,
|
|
||||||
} from "../../core/stores/messageStore/index.ts";
|
|
||||||
|
|
||||||
export interface NodeOptionsDialogProps {
|
|
||||||
node: Protobuf.Mesh.NodeInfo | undefined;
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const NodeOptionsDialog = ({
|
|
||||||
node,
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
}: NodeOptionsDialogProps) => {
|
|
||||||
const { setDialogOpen, connection, setActivePage } = useDevice();
|
|
||||||
const {
|
|
||||||
setNodeNumToBeRemoved,
|
|
||||||
setNodeNumDetails,
|
|
||||||
} = useAppStore();
|
|
||||||
const { setChatType, setActiveChat } = useMessageStore();
|
|
||||||
|
|
||||||
if (!node) return null;
|
|
||||||
|
|
||||||
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() {
|
|
||||||
setChatType(MessageType.Direct);
|
|
||||||
setActiveChat(node.num);
|
|
||||||
setActivePage("messages");
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleRequestPosition() {
|
|
||||||
toast({
|
|
||||||
title: "Requesting position, please wait...",
|
|
||||||
});
|
|
||||||
connection?.requestPosition(node.num).then(() =>
|
|
||||||
toast({
|
|
||||||
title: "Position request sent.",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
onOpenChange();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleTraceroute() {
|
|
||||||
toast({
|
|
||||||
title: "Sending Traceroute, please wait...",
|
|
||||||
});
|
|
||||||
connection?.traceRoute(node.num).then(() =>
|
|
||||||
toast({
|
|
||||||
title: "Traceroute sent.",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
onOpenChange();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent aria-describedby={undefined}>
|
|
||||||
<DialogClose />
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{`${longName} (${shortName})`}</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="flex flex-col space-y-1">
|
|
||||||
<div>
|
|
||||||
<Button onClick={handleDirectMessage}>Direct Message</Button>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Button onClick={handleRequestPosition}>Request Position</Button>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Button onClick={handleTraceroute}>Trace Route</Button>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Button
|
|
||||||
key="remove"
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() => {
|
|
||||||
setNodeNumToBeRemoved(node?.num);
|
|
||||||
setDialogOpen("nodeRemoval", true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TrashIcon />
|
|
||||||
Remove
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
setNodeNumDetails(node?.num);
|
|
||||||
setDialogOpen("nodeDetails", true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
More Details
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -87,14 +87,20 @@ export const MessageItem = ({ message }: MessageItemProps) => {
|
|||||||
}, [getNode, message.from]);
|
}, [getNode, message.from]);
|
||||||
|
|
||||||
const myNodeNum = useMemo(() => getMyNodeNum(), [getMyNodeNum]);
|
const myNodeNum = useMemo(() => getMyNodeNum(), [getMyNodeNum]);
|
||||||
const { displayName, shortName } = useMemo(() => {
|
const { displayName, shortName, isFavorite } = useMemo(() => {
|
||||||
const userIdHex = message.from.toString(16).toUpperCase().padStart(2, "0");
|
const userIdHex = message.from.toString(16).toUpperCase().padStart(2, "0");
|
||||||
const last4 = userIdHex.slice(-4);
|
const last4 = userIdHex.slice(-4);
|
||||||
const fallbackName = `Meshtastic ${last4}`;
|
const fallbackName = `Meshtastic ${last4}`;
|
||||||
const longName = messageUser?.user?.longName;
|
const longName = messageUser?.user?.longName;
|
||||||
const derivedShortName = messageUser?.user?.shortName || fallbackName;
|
const derivedShortName = messageUser?.user?.shortName || fallbackName;
|
||||||
const derivedDisplayName = longName || derivedShortName;
|
const derivedDisplayName = longName || derivedShortName;
|
||||||
return { displayName: derivedDisplayName, shortName: derivedShortName };
|
const isFavorite = messageUser?.num !== myNodeNum &&
|
||||||
|
messageUser?.isFavorite;
|
||||||
|
return {
|
||||||
|
displayName: derivedDisplayName,
|
||||||
|
shortName: derivedShortName,
|
||||||
|
isFavorite: isFavorite,
|
||||||
|
};
|
||||||
}, [messageUser, message.from]);
|
}, [messageUser, message.from]);
|
||||||
|
|
||||||
const messageStatusInfo = getMessageStatusInfo(message.state);
|
const messageStatusInfo = getMessageStatusInfo(message.state);
|
||||||
@@ -140,7 +146,12 @@ export const MessageItem = ({ message }: MessageItemProps) => {
|
|||||||
return (
|
return (
|
||||||
<li className={messageItemWrapperClass}>
|
<li className={messageItemWrapperClass}>
|
||||||
<div className="grid grid-cols-[auto_1fr] gap-x-2">
|
<div className="grid grid-cols-[auto_1fr] gap-x-2">
|
||||||
<Avatar size="sm" text={shortName} className="pt-0.5" />
|
<Avatar
|
||||||
|
size="sm"
|
||||||
|
text={shortName}
|
||||||
|
className="pt-0.5"
|
||||||
|
showFavorite={isFavorite}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex flex-col gap-0.5 min-w-0">
|
<div className="flex flex-col gap-0.5 min-w-0">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ interface NavLink {
|
|||||||
name: string;
|
name: string;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
page: Page;
|
page: Page;
|
||||||
|
count?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CollapseToggleButton = () => {
|
const CollapseToggleButton = () => {
|
||||||
@@ -71,6 +72,7 @@ export const Sidebar = ({ children }: SidebarProps) => {
|
|||||||
getNodesLength,
|
getNodesLength,
|
||||||
metadata,
|
metadata,
|
||||||
activePage,
|
activePage,
|
||||||
|
unreadCounts,
|
||||||
setActivePage,
|
setActivePage,
|
||||||
setDialogOpen,
|
setDialogOpen,
|
||||||
} = useDevice();
|
} = useDevice();
|
||||||
@@ -79,8 +81,15 @@ export const Sidebar = ({ children }: SidebarProps) => {
|
|||||||
const { isCollapsed } = useSidebar();
|
const { isCollapsed } = useSidebar();
|
||||||
const myMetadata = metadata.get(0);
|
const myMetadata = metadata.get(0);
|
||||||
|
|
||||||
|
const numUnread = [...unreadCounts.values()].reduce((sum, v) => sum + v, 0);
|
||||||
|
|
||||||
const pages: NavLink[] = [
|
const pages: NavLink[] = [
|
||||||
{ name: "Messages", icon: MessageSquareIcon, page: "messages" },
|
{
|
||||||
|
name: "Messages",
|
||||||
|
icon: MessageSquareIcon,
|
||||||
|
page: "messages",
|
||||||
|
count: numUnread ? numUnread : undefined,
|
||||||
|
},
|
||||||
{ name: "Map", icon: MapIcon, page: "map" },
|
{ name: "Map", icon: MapIcon, page: "map" },
|
||||||
{ name: "Config", icon: SettingsIcon, page: "config" },
|
{ name: "Config", icon: SettingsIcon, page: "config" },
|
||||||
{ name: "Channels", icon: LayersIcon, page: "channels" },
|
{ name: "Channels", icon: LayersIcon, page: "channels" },
|
||||||
@@ -130,6 +139,7 @@ export const Sidebar = ({ children }: SidebarProps) => {
|
|||||||
{pages.map((link) => (
|
{pages.map((link) => (
|
||||||
<SidebarButton
|
<SidebarButton
|
||||||
key={link.name}
|
key={link.name}
|
||||||
|
count={link.count}
|
||||||
label={link.name}
|
label={link.name}
|
||||||
Icon={link.icon}
|
Icon={link.icon}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { cn } from "@core/utils/cn.ts";
|
import { cn } from "@core/utils/cn.ts";
|
||||||
import { LockKeyholeOpenIcon } from "lucide-react";
|
import { LockKeyholeOpenIcon, StarIcon } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipArrow,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@components/UI/Tooltip.tsx";
|
||||||
|
|
||||||
type RGBColor = {
|
type RGBColor = {
|
||||||
r: number;
|
r: number;
|
||||||
@@ -13,6 +20,7 @@ interface AvatarProps {
|
|||||||
size?: "sm" | "lg";
|
size?: "sm" | "lg";
|
||||||
className?: string;
|
className?: string;
|
||||||
showError?: boolean;
|
showError?: boolean;
|
||||||
|
showFavorite?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
class ColorUtils {
|
class ColorUtils {
|
||||||
@@ -62,6 +70,7 @@ export const Avatar = ({
|
|||||||
text,
|
text,
|
||||||
size = "sm",
|
size = "sm",
|
||||||
showError = false,
|
showError = false,
|
||||||
|
showFavorite = false,
|
||||||
className,
|
className,
|
||||||
}: AvatarProps) => {
|
}: AvatarProps) => {
|
||||||
const sizes = {
|
const sizes = {
|
||||||
@@ -88,12 +97,43 @@ export const Avatar = ({
|
|||||||
color: textColor,
|
color: textColor,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{showFavorite
|
||||||
|
? (
|
||||||
|
<TooltipProvider delayDuration={300}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<StarIcon
|
||||||
|
className="absolute -top-0.5 -right-0.5 z-10 size-4 stroke-1 fill-yellow-400"
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
color: `rgb(${bgColor.r}, ${bgColor.g}, ${bgColor.b})`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs">
|
||||||
|
Favorite
|
||||||
|
<TooltipArrow className="fill-slate-800 dark:fill-slate-600" />
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
{showError
|
{showError
|
||||||
? (
|
? (
|
||||||
<LockKeyholeOpenIcon
|
<TooltipProvider delayDuration={300}>
|
||||||
className="absolute bottom-0 right-0 z-10 size-4 text-red-500 stroke-3"
|
<Tooltip>
|
||||||
aria-hidden="true"
|
<TooltipTrigger asChild>
|
||||||
/>
|
<LockKeyholeOpenIcon
|
||||||
|
className="absolute -bottom-0.5 -right-0.5 z-10 size-4 text-red-500 stroke-3"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs">
|
||||||
|
Node error
|
||||||
|
<TooltipArrow className="fill-slate-800 dark:fill-slate-600" />
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
<p className="p-1">
|
<p className="p-1">
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { cn } from "@core/utils/cn.ts";
|
||||||
|
|
||||||
|
interface FavoriteIconProps {
|
||||||
|
showFavorite: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AvatarCellProps {
|
||||||
|
children: React.ReactElement<FavoriteIconProps>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TableProps {
|
export interface TableProps {
|
||||||
headings: Heading[];
|
headings: Heading[];
|
||||||
rows: React.ReactNode[][];
|
rows: React.ReactElement<AvatarCellProps>[][];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Heading {
|
export interface Heading {
|
||||||
@@ -61,6 +70,13 @@ export const Table = ({ headings, rows }: TableProps) => {
|
|||||||
const elementA = getElement(a[columnIndex]);
|
const elementA = getElement(a[columnIndex]);
|
||||||
const elementB = getElement(b[columnIndex]);
|
const elementB = getElement(b[columnIndex]);
|
||||||
|
|
||||||
|
// Avatar contains the prop showFavorite which indicates isFavorite
|
||||||
|
const favA = a[0]?.props?.children?.props?.showFavorite ?? false;
|
||||||
|
const favB = b[0]?.props?.children?.props?.showFavorite ?? false;
|
||||||
|
|
||||||
|
// Always put favorites at the top
|
||||||
|
if (favA !== favB) return favA ? -1 : 1;
|
||||||
|
|
||||||
if (sortColumn === "Last Heard") {
|
if (sortColumn === "Last Heard") {
|
||||||
const aTimestamp = elementA?.props?.children?.props?.timestamp ?? 0;
|
const aTimestamp = elementA?.props?.children?.props?.timestamp ?? 0;
|
||||||
const bTimestamp = elementB?.props?.children?.props?.timestamp ?? 0;
|
const bTimestamp = elementB?.props?.children?.props?.timestamp ?? 0;
|
||||||
@@ -142,13 +158,17 @@ export const Table = ({ headings, rows }: TableProps) => {
|
|||||||
: null;
|
: null;
|
||||||
const rowKey = firstCellKey ?? Math.random().toString(); // Use random only as last resort
|
const rowKey = firstCellKey ?? Math.random().toString(); // Use random only as last resort
|
||||||
|
|
||||||
|
const isFavorite = row[0]?.props?.children?.props?.showFavorite ??
|
||||||
|
false;
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={rowKey}
|
key={rowKey}
|
||||||
className={`
|
className={cn(
|
||||||
bg-white dark:bg-slate-900
|
"",
|
||||||
odd:bg-slate-200/40 dark:odd:bg-slate-800/40
|
isFavorite
|
||||||
`}
|
? "bg-yellow-100/30 dark:bg-slate-800 odd:bg-yellow-200/30 dark:odd:bg-slate-600/40"
|
||||||
|
: "bg-white dark:bg-slate-900 odd:bg-slate-200/40 dark:odd:bg-slate-800/40",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{row.map((item, cellIndex) => {
|
{row.map((item, cellIndex) => {
|
||||||
const cellKey = `${rowKey}_${cellIndex}`;
|
const cellKey = `${rowKey}_${cellIndex}`;
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { useFavoriteNode } from "./useFavoriteNode.ts";
|
||||||
|
import { Protobuf } from "@meshtastic/core";
|
||||||
|
|
||||||
|
const mockNode = {
|
||||||
|
num: 1234,
|
||||||
|
user: {
|
||||||
|
longName: "Test Node",
|
||||||
|
},
|
||||||
|
isFavorite: true,
|
||||||
|
} as unknown | Protobuf.Mesh.NodeInfo;
|
||||||
|
|
||||||
|
const mockUpdateFavorite = vi.fn();
|
||||||
|
const mockGetNode = vi.fn(() => mockNode);
|
||||||
|
const mockToast = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("@core/stores/deviceStore.ts", () => ({
|
||||||
|
useDevice: () => ({
|
||||||
|
updateFavorite: mockUpdateFavorite,
|
||||||
|
getNode: mockGetNode,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@core/hooks/useToast.ts", () => ({
|
||||||
|
useToast: () => ({
|
||||||
|
toast: mockToast,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("useFavoriteNode hook", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls updateFavorite and shows correct toast", () => {
|
||||||
|
const { result } = renderHook(() => useFavoriteNode());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.updateFavorite({ nodeNum: 1234, isFavorite: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockUpdateFavorite).toHaveBeenCalledWith(1234, true);
|
||||||
|
expect(mockGetNode).toHaveBeenCalledWith(1234);
|
||||||
|
expect(mockToast).toHaveBeenCalledWith({
|
||||||
|
title: "Added Test Node to favorites",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles removal case", () => {
|
||||||
|
const { result } = renderHook(() => useFavoriteNode());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.updateFavorite({ nodeNum: 1234, isFavorite: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockUpdateFavorite).toHaveBeenCalledWith(1234, false);
|
||||||
|
expect(mockGetNode).toHaveBeenCalledWith(1234);
|
||||||
|
expect(mockToast).toHaveBeenCalledWith({
|
||||||
|
title: "Removed Test Node from favorites",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to 'node' if longName is missing", () => {
|
||||||
|
mockGetNode.mockReturnValueOnce({
|
||||||
|
num: 5678,
|
||||||
|
user: {},
|
||||||
|
}); // no longName
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useFavoriteNode());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.updateFavorite({ nodeNum: 5678, isFavorite: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockToast).toHaveBeenCalledWith({
|
||||||
|
title: "Added node to favorites",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to 'node' if getNode returns undefined", () => {
|
||||||
|
mockGetNode.mockReturnValueOnce(undefined);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useFavoriteNode());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.updateFavorite({ nodeNum: 9999, isFavorite: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockUpdateFavorite).not.toHaveBeenCalled();
|
||||||
|
expect(mockToast).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
|
import { useToast } from "@core/hooks/useToast.ts";
|
||||||
|
|
||||||
|
interface FavoriteNodeOptions {
|
||||||
|
nodeNum: number;
|
||||||
|
isFavorite: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFavoriteNode() {
|
||||||
|
const { updateFavorite, getNode } = useDevice();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const updateFavoriteCB = useCallback(
|
||||||
|
({ nodeNum, isFavorite }: FavoriteNodeOptions) => {
|
||||||
|
const node = getNode(nodeNum);
|
||||||
|
if (!node) return;
|
||||||
|
|
||||||
|
updateFavorite(nodeNum, isFavorite);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: `${isFavorite ? "Added" : "Removed"} ${
|
||||||
|
node?.user?.longName ?? "node"
|
||||||
|
} ${isFavorite ? "to" : "from"} favorites`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[updateFavorite, getNode],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { updateFavorite: updateFavoriteCB };
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { useIgnoreNode } from "./useIgnoreNode.ts";
|
||||||
|
import { Protobuf } from "@meshtastic/core";
|
||||||
|
|
||||||
|
const mockNode = {
|
||||||
|
num: 1234,
|
||||||
|
user: {
|
||||||
|
longName: "Test Node",
|
||||||
|
},
|
||||||
|
isIgnored: true,
|
||||||
|
} as unknown | Protobuf.Mesh.NodeInfo;
|
||||||
|
|
||||||
|
const mockUpdateIgnore = vi.fn();
|
||||||
|
const mockGetNode = vi.fn(() => mockNode);
|
||||||
|
const mockToast = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("@core/stores/deviceStore.ts", () => ({
|
||||||
|
useDevice: () => ({
|
||||||
|
updateIgnored: mockUpdateIgnore,
|
||||||
|
getNode: mockGetNode,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@core/hooks/useToast.ts", () => ({
|
||||||
|
useToast: () => ({
|
||||||
|
toast: mockToast,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("useIgnoreNode hook", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls updateIgnored and shows correct toast", () => {
|
||||||
|
const { result } = renderHook(() => useIgnoreNode());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.updateIgnored({ nodeNum: 1234, isIgnored: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockUpdateIgnore).toHaveBeenCalledWith(1234, true);
|
||||||
|
expect(mockGetNode).toHaveBeenCalledWith(1234);
|
||||||
|
expect(mockToast).toHaveBeenCalledWith({
|
||||||
|
title: "Added Test Node to ignore list",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles removal case", () => {
|
||||||
|
const { result } = renderHook(() => useIgnoreNode());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.updateIgnored({
|
||||||
|
nodeNum: 1234,
|
||||||
|
isIgnored: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockUpdateIgnore).toHaveBeenCalledWith(1234, false);
|
||||||
|
expect(mockGetNode).toHaveBeenCalledWith(1234);
|
||||||
|
expect(mockToast).toHaveBeenCalledWith({
|
||||||
|
title: "Removed Test Node from ignore list",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to 'node' if longName is missing", () => {
|
||||||
|
mockGetNode.mockReturnValueOnce({
|
||||||
|
num: 5678,
|
||||||
|
user: {},
|
||||||
|
}); // no longName
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useIgnoreNode());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.updateIgnored({ nodeNum: 5678, isIgnored: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockToast).toHaveBeenCalledWith({
|
||||||
|
title: "Added node to ignore list",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to 'node' if getNode returns undefined", () => {
|
||||||
|
mockGetNode.mockReturnValueOnce(undefined);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useIgnoreNode());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.updateIgnored({ nodeNum: 9999, isIgnored: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockUpdateIgnore).not.toHaveBeenCalled();
|
||||||
|
expect(mockToast).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
|
import { useToast } from "@core/hooks/useToast.ts";
|
||||||
|
|
||||||
|
interface IgnoreNodeOptions {
|
||||||
|
nodeNum: number;
|
||||||
|
isIgnored: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useIgnoreNode() {
|
||||||
|
const { updateIgnored, getNode } = useDevice();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const updateIgnoredCB = useCallback(
|
||||||
|
({ nodeNum, isIgnored }: IgnoreNodeOptions) => {
|
||||||
|
const node = getNode(nodeNum);
|
||||||
|
if (!node) return;
|
||||||
|
|
||||||
|
updateIgnored(nodeNum, isIgnored);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: `${isIgnored ? "Added" : "Removed"} ${
|
||||||
|
node?.user?.longName ?? "node"
|
||||||
|
} ${isIgnored ? "to" : "from"} ignore list`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[updateIgnored, getNode],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { updateIgnored: updateIgnoredCB };
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { create } from "@bufbuild/protobuf";
|
import { create, toBinary } from "@bufbuild/protobuf";
|
||||||
import { MeshDevice, Protobuf, Types } from "@meshtastic/core";
|
import { MeshDevice, Protobuf, Types } from "@meshtastic/core";
|
||||||
import { produce } from "immer";
|
import { produce } from "immer";
|
||||||
import { createContext, useContext } from "react";
|
import { createContext, useContext } from "react";
|
||||||
@@ -93,6 +93,9 @@ 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;
|
||||||
|
sendAdminMessage: (message: Protobuf.Admin.AdminMessage) => void;
|
||||||
|
updateFavorite: (nodeNum: number, isFavorite: boolean) => void;
|
||||||
|
updateIgnored: (nodeNum: number, isIgnored: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeviceState {
|
export interface DeviceState {
|
||||||
@@ -594,6 +597,60 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
return device.nodesMap.size;
|
return device.nodesMap.size;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
sendAdminMessage(message: Protobuf.Admin.AdminMessage) {
|
||||||
|
const device = get().devices.get(id);
|
||||||
|
if (!device) return;
|
||||||
|
|
||||||
|
device.connection?.sendPacket(
|
||||||
|
toBinary(Protobuf.Admin.AdminMessageSchema, message),
|
||||||
|
Protobuf.Portnums.PortNum.ADMIN_APP,
|
||||||
|
"self",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
updateFavorite(nodeNum: number, isFavorite: boolean) {
|
||||||
|
const device = get().devices.get(id);
|
||||||
|
if (!device) return;
|
||||||
|
const node = device?.nodesMap.get(nodeNum);
|
||||||
|
if (!node) return;
|
||||||
|
|
||||||
|
device.sendAdminMessage(create(Protobuf.Admin.AdminMessageSchema, {
|
||||||
|
payloadVariant: {
|
||||||
|
case: isFavorite ? "setFavoriteNode" : "removeFavoriteNode",
|
||||||
|
value: nodeNum,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
set(
|
||||||
|
produce<DeviceState>((draft) => {
|
||||||
|
const device = draft.devices.get(id);
|
||||||
|
const node = device?.nodesMap.get(nodeNum);
|
||||||
|
node.isFavorite = isFavorite;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
updateIgnored(nodeNum: number, isIgnored: boolean) {
|
||||||
|
const device = get().devices.get(id);
|
||||||
|
if (!device) return;
|
||||||
|
const node = device?.nodesMap.get(nodeNum);
|
||||||
|
if (!node) return;
|
||||||
|
|
||||||
|
device.sendAdminMessage(create(Protobuf.Admin.AdminMessageSchema, {
|
||||||
|
payloadVariant: {
|
||||||
|
case: isIgnored ? "setIgnoredNode" : "removeIgnoredNode",
|
||||||
|
value: nodeNum,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
set(
|
||||||
|
produce<DeviceState>((draft) => {
|
||||||
|
const device = draft.devices.get(id);
|
||||||
|
const node = device?.nodesMap.get(nodeNum);
|
||||||
|
node.isIgnored = isIgnored;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const convertToLatLng = (position: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const MapPage = () => {
|
const MapPage = () => {
|
||||||
const { getNodes, waypoints } = useDevice();
|
const { getNodes, waypoints, hasNodeError } = useDevice();
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const { default: map } = useMap();
|
const { default: map } = useMap();
|
||||||
|
|
||||||
@@ -146,6 +146,8 @@ const MapPage = () => {
|
|||||||
<Avatar
|
<Avatar
|
||||||
text={node.user?.shortName?.toString() ?? node.num.toString()}
|
text={node.user?.shortName?.toString() ?? node.num.toString()}
|
||||||
className="border-[1.5px] border-slate-600 shadow-xl shadow-slate-600"
|
className="border-[1.5px] border-slate-600 shadow-xl shadow-slate-600"
|
||||||
|
showError={hasNodeError(node.num)}
|
||||||
|
showFavorite={node.isFavorite}
|
||||||
/>
|
/>
|
||||||
</Marker>
|
</Marker>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -60,7 +60,11 @@ export const MessagesPage = () => {
|
|||||||
...node,
|
...node,
|
||||||
unreadCount: unreadCounts.get(node.num) ?? 0,
|
unreadCount: unreadCounts.get(node.num) ?? 0,
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => b.unreadCount - a.unreadCount);
|
.sort((a, b) => {
|
||||||
|
const diff = b.unreadCount - a.unreadCount;
|
||||||
|
if (diff !== 0) return diff;
|
||||||
|
return Number(b.isFavorite) - Number(a.isFavorite);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const allChannels = Array.from(channels.values());
|
const allChannels = Array.from(channels.values());
|
||||||
@@ -239,6 +243,7 @@ export const MessagesPage = () => {
|
|||||||
text={node.user?.shortName ?? "UNK"}
|
text={node.user?.shortName ?? "UNK"}
|
||||||
className={cn(hasNodeError(node.num) && "text-red-500")}
|
className={cn(hasNodeError(node.num) && "text-red-500")}
|
||||||
showError={hasNodeError(node.num)}
|
showError={hasNodeError(node.num)}
|
||||||
|
showFavorite={node.isFavorite}
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
</SidebarButton>
|
</SidebarButton>
|
||||||
|
|||||||
+9
-6
@@ -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";
|
||||||
@@ -27,7 +27,7 @@ export interface DeleteNoteDialogProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const NodesPage = (): JSX.Element => {
|
const NodesPage = (): JSX.Element => {
|
||||||
const { getNodes, hardware, connection } = useDevice();
|
const { getNodes, hardware, connection, hasNodeError } = useDevice();
|
||||||
const [selectedNode, setSelectedNode] = useState<
|
const [selectedNode, setSelectedNode] = useState<
|
||||||
Protobuf.Mesh.NodeInfo | undefined
|
Protobuf.Mesh.NodeInfo | undefined
|
||||||
>(undefined);
|
>(undefined);
|
||||||
@@ -35,7 +35,7 @@ const NodesPage = (): JSX.Element => {
|
|||||||
Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery> | undefined
|
Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery> | undefined
|
||||||
>();
|
>();
|
||||||
const [selectedLocation, setSelectedLocation] = useState<
|
const [selectedLocation, setSelectedLocation] = useState<
|
||||||
Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery> | undefined
|
Types.PacketMetadata<Protobuf.Mesh.Position> | undefined
|
||||||
>();
|
>();
|
||||||
const [searchTerm, setSearchTerm] = useState<string>("");
|
const [searchTerm, setSearchTerm] = useState<string>("");
|
||||||
const deferredSearch = useDeferredValue(searchTerm);
|
const deferredSearch = useDeferredValue(searchTerm);
|
||||||
@@ -84,7 +84,6 @@ const NodesPage = (): JSX.Element => {
|
|||||||
<PageLayout
|
<PageLayout
|
||||||
label=""
|
label=""
|
||||||
leftBar={<Sidebar />}
|
leftBar={<Sidebar />}
|
||||||
className="flex flex-col w-full"
|
|
||||||
>
|
>
|
||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
<Input
|
<Input
|
||||||
@@ -109,7 +108,11 @@ const NodesPage = (): JSX.Element => {
|
|||||||
]}
|
]}
|
||||||
rows={filteredNodes.map((node) => [
|
rows={filteredNodes.map((node) => [
|
||||||
<div key={node.num}>
|
<div key={node.num}>
|
||||||
<Avatar text={node.user?.shortName ?? "UNK "} />
|
<Avatar
|
||||||
|
text={node.user?.shortName ?? "UNK "}
|
||||||
|
showFavorite={node.isFavorite}
|
||||||
|
showError={hasNodeError(node.num)}
|
||||||
|
/>
|
||||||
</div>,
|
</div>,
|
||||||
<h1
|
<h1
|
||||||
key="longName"
|
key="longName"
|
||||||
@@ -159,7 +162,7 @@ const NodesPage = (): JSX.Element => {
|
|||||||
</Mono>,
|
</Mono>,
|
||||||
])}
|
])}
|
||||||
/>
|
/>
|
||||||
<NodeOptionsDialog
|
<NodeDetailsDialog
|
||||||
node={selectedNode}
|
node={selectedNode}
|
||||||
open={!!selectedNode}
|
open={!!selectedNode}
|
||||||
onOpenChange={() => setSelectedNode(undefined)}
|
onOpenChange={() => setSelectedNode(undefined)}
|
||||||
|
|||||||
Reference in New Issue
Block a user