Browse Source

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
pull/620/head v2.6.4
philon- 1 year ago
committed by GitHub
parent
commit
513a285fee
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 81
      src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.test.tsx
  2. 311
      src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx
  3. 120
      src/components/Dialog/NodeOptionsDialog.tsx
  4. 17
      src/components/PageComponents/Messages/MessageItem.tsx
  5. 12
      src/components/Sidebar.tsx
  6. 50
      src/components/UI/Avatar.tsx
  7. 30
      src/components/generic/Table/index.tsx
  8. 93
      src/core/hooks/useFavoriteNode.test.ts
  9. 31
      src/core/hooks/useFavoriteNode.ts
  10. 96
      src/core/hooks/useIgnoreNode.test.ts
  11. 31
      src/core/hooks/useIgnoreNode.ts
  12. 59
      src/core/stores/deviceStore.ts
  13. 4
      src/pages/Map/index.tsx
  14. 7
      src/pages/Messages.tsx
  15. 15
      src/pages/Nodes.tsx

81
src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.test.tsx

@ -1,18 +1,22 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useAppStore } from "@core/stores/appStore.ts";
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");
const mockUseDevice = vi.mocked(useDevice);
const mockUseAppStore = vi.mocked(useAppStore);
describe("NodeDetailsDialog", () => {
const mockDevice = {
const mockNode = {
num: 1234,
user: {
longName: "Test Node",
@ -38,22 +42,13 @@ describe("NodeDetailsDialog", () => {
beforeEach(() => {
vi.resetAllMocks();
mockUseDevice.mockReturnValue({
getNode: (nodeNum: number) => {
if (nodeNum === 1234) {
return mockDevice;
}
return undefined;
},
});
mockUseAppStore.mockReturnValue({
nodeNumDetails: 1234,
});
});
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))
.toBeInTheDocument();
@ -82,27 +77,11 @@ describe("NodeDetailsDialog", () => {
expect(screen.getByText(/All Raw Metrics:/i)).toBeInTheDocument();
});
it("renders null if device is not found", () => {
const requestedNodeNum = 5678;
mockUseAppStore.mockReturnValue({
nodeNumDetails: requestedNodeNum,
});
mockUseDevice.mockReturnValue({
getNode: (nodeNum: number) => {
if (nodeNum === requestedNodeNum) {
return undefined;
}
if (nodeNum === 1234) {
return mockDevice;
}
return undefined;
},
});
it("renders null if node is undefined", () => {
const mockNode = undefined;
const { container } = render(
<NodeDetailsDialog open onOpenChange={() => {}} />,
<NodeDetailsDialog open node={mockNode} onOpenChange={() => {}} />,
);
expect(container.firstChild).toBeNull();
@ -110,11 +89,15 @@ describe("NodeDetailsDialog", () => {
});
it("renders correctly when position is missing", () => {
const nodeWithoutPosition = { ...mockDevice, position: undefined };
mockUseDevice.mockReturnValue({ getNode: () => nodeWithoutPosition });
mockUseAppStore.mockReturnValue({ nodeNumDetails: 1234 });
render(<NodeDetailsDialog open onOpenChange={() => {}} />);
const nodeWithoutPosition = { ...mockNode, position: undefined };
render(
<NodeDetailsDialog
open
node={nodeWithoutPosition}
onOpenChange={() => {}}
/>,
);
expect(screen.queryByText(/Coordinates:/i)).not.toBeInTheDocument();
expect(screen.queryByText(/Altitude:/i)).not.toBeInTheDocument();
@ -122,11 +105,15 @@ describe("NodeDetailsDialog", () => {
});
it("renders correctly when deviceMetrics are missing", () => {
const nodeWithoutMetrics = { ...mockDevice, deviceMetrics: undefined };
mockUseDevice.mockReturnValue({ getNode: () => nodeWithoutMetrics });
mockUseAppStore.mockReturnValue({ nodeNumDetails: 1234 });
render(<NodeDetailsDialog open onOpenChange={() => {}} />);
const nodeWithoutMetrics = { ...mockNode, deviceMetrics: undefined };
render(
<NodeDetailsDialog
open
node={nodeWithoutMetrics}
onOpenChange={() => {}}
/>,
);
expect(screen.queryByText(/Device Metrics:/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", () => {
const nodeNeverHeard = { ...mockDevice, lastHeard: 0 };
mockUseDevice.mockReturnValue({ getNode: () => nodeNeverHeard });
mockUseAppStore.mockReturnValue({ nodeNumDetails: 1234 });
const nodeNeverHeard = { ...mockNode, lastHeard: 0 };
render(<NodeDetailsDialog open onOpenChange={() => {}} />);
render(
<NodeDetailsDialog open node={nodeNeverHeard} onOpenChange={() => {}} />,
);
expect(screen.getByText(/Last Heard: Never/i)).toBeInTheDocument();
});

311
src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx

@ -1,5 +1,21 @@
import { useEffect, useState } from "react";
import { useAppStore } from "@core/stores/appStore.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 {
Accordion,
AccordionContent,
@ -14,51 +30,135 @@ import {
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
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 { Button } from "@components/UI/Button.tsx";
import {
BellIcon,
BellOffIcon,
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 {
node: Protobuf.Mesh.NodeInfo | undefined;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const NodeDetailsDialog = ({
node,
open,
onOpenChange,
}: NodeDetailsDialogProps) => {
const { getNode } = useDevice();
const { nodeNumDetails } = useAppStore();
const { setDialogOpen, connection, setActivePage } = useDevice();
const { setNodeNumToBeRemoved } = useAppStore();
const { setChatType, setActiveChat } = useMessageStore();
const { updateFavorite } = useFavoriteNode();
const [isFavoriteState, setIsFavoriteState] = useState<boolean>(false);
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);
}
const device = getNode(nodeNumDetails);
function handleNodeRemove() {
if (!node) return;
if (!device) return null;
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 = [
{
key: "airUtilTx",
label: "Air TX utilization",
value: device.deviceMetrics?.airUtilTx,
value: node.deviceMetrics?.airUtilTx,
format: (val: number) => `${val.toFixed(2)}%`,
},
{
key: "channelUtilization",
label: "Channel utilization",
value: device.deviceMetrics?.channelUtilization,
value: node.deviceMetrics?.channelUtilization,
format: (val: number) => `${val.toFixed(2)}%`,
},
{
key: "batteryLevel",
label: "Battery level",
value: device.deviceMetrics?.batteryLevel,
value: node.deviceMetrics?.batteryLevel,
format: (val: number) => `${val.toFixed(2)}%`,
},
{
key: "voltage",
label: "Voltage",
value: device.deviceMetrics?.voltage,
value: node.deviceMetrics?.voltage,
format: (val: number) => `${val.toFixed(2)}V`,
},
];
@ -69,64 +169,141 @@ export const NodeDetailsDialog = ({
<DialogClose />
<DialogHeader>
<DialogTitle>
Node Details for {device.user?.longName ?? "UNKNOWN"} (
{device.user?.shortName ?? "UNK"})
Node Details for {node.user?.longName ?? "UNKNOWN"} (
{node.user?.shortName ?? "UNK"})
</DialogTitle>
</DialogHeader>
<DialogFooter>
<div className="w-full">
<div className="flex flex-col">
<DeviceImage
className="w-32 h-32 mx-auto rounded-lg border-4 border-slate-200 dark:border-slate-800"
deviceType={Protobuf.Mesh
.HardwareModel[device.user?.hwModel ?? 0]}
/>
<div className="bg-slate-100 text-slate-900 dark:text-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
<p className="text-lg font-semibold">Details:</p>
<p>
Hardware:{" "}
{Protobuf.Mesh.HardwareModel[device.user?.hwModel ?? 0]}
</p>
<p>Node Number: {device.num}</p>
<p>Node Hex: !{numberToHexUnpadded(device.num)}</p>
<p>
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 && (
<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 className="flex flex-row flex-wrap space-y-1">
<Button className="mr-1" onClick={handleDirectMessage}>
<MessageSquareIcon className="mr-2" />
Message
</Button>
<Button className="mr-1" onClick={handleTraceroute}>
<WaypointsIcon className="mr-2" />
Trace Route
</Button>
<Button className="mr-1" onClick={handleToggleFavorite}>
<StarIcon
className={cn(
isFavoriteState ? " fill-yellow-400 stroke-yellow-400" : "",
)}
/>
</Button>
<div className="flex flex-1 justify-start"></div>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<Button
className={cn(
"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>
{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">
<p className="text-lg font-semibold text-slate-900 dark:text-slate-100">
Device Metrics:
@ -139,10 +316,10 @@ export const NodeDetailsDialog = ({
</p>
),
)}
{device.deviceMetrics.uptimeSeconds && (
{node.deviceMetrics.uptimeSeconds && (
<p>
Uptime:{" "}
<Uptime seconds={device.deviceMetrics.uptimeSeconds} />
<Uptime seconds={node.deviceMetrics.uptimeSeconds} />
</p>
)}
</div>
@ -159,7 +336,7 @@ export const NodeDetailsDialog = ({
</AccordionTrigger>
<AccordionContent className="overflow-x-scroll">
<pre className="text-xs w-full">
{JSON.stringify(device, null, 2)}
{JSON.stringify(node, null, 2)}
</pre>
</AccordionContent>
</AccordionItem>

120
src/components/Dialog/NodeOptionsDialog.tsx

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

17
src/components/PageComponents/Messages/MessageItem.tsx

@ -87,14 +87,20 @@ export const MessageItem = ({ message }: MessageItemProps) => {
}, [getNode, message.from]);
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 last4 = userIdHex.slice(-4);
const fallbackName = `Meshtastic ${last4}`;
const longName = messageUser?.user?.longName;
const derivedShortName = messageUser?.user?.shortName || fallbackName;
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]);
const messageStatusInfo = getMessageStatusInfo(message.state);
@ -140,7 +146,12 @@ export const MessageItem = ({ message }: MessageItemProps) => {
return (
<li className={messageItemWrapperClass}>
<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 items-center gap-1.5">

12
src/components/Sidebar.tsx

@ -34,6 +34,7 @@ interface NavLink {
name: string;
icon: LucideIcon;
page: Page;
count?: number;
}
const CollapseToggleButton = () => {
@ -71,6 +72,7 @@ export const Sidebar = ({ children }: SidebarProps) => {
getNodesLength,
metadata,
activePage,
unreadCounts,
setActivePage,
setDialogOpen,
} = useDevice();
@ -79,8 +81,15 @@ export const Sidebar = ({ children }: SidebarProps) => {
const { isCollapsed } = useSidebar();
const myMetadata = metadata.get(0);
const numUnread = [...unreadCounts.values()].reduce((sum, v) => sum + v, 0);
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: "Config", icon: SettingsIcon, page: "config" },
{ name: "Channels", icon: LayersIcon, page: "channels" },
@ -130,6 +139,7 @@ export const Sidebar = ({ children }: SidebarProps) => {
{pages.map((link) => (
<SidebarButton
key={link.name}
count={link.count}
label={link.name}
Icon={link.icon}
onClick={() => {

50
src/components/UI/Avatar.tsx

@ -1,5 +1,12 @@
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 = {
r: number;
@ -13,6 +20,7 @@ interface AvatarProps {
size?: "sm" | "lg";
className?: string;
showError?: boolean;
showFavorite?: boolean;
}
class ColorUtils {
@ -62,6 +70,7 @@ export const Avatar = ({
text,
size = "sm",
showError = false,
showFavorite = false,
className,
}: AvatarProps) => {
const sizes = {
@ -88,12 +97,43 @@ export const Avatar = ({
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
? (
<LockKeyholeOpenIcon
className="absolute bottom-0 right-0 z-10 size-4 text-red-500 stroke-3"
aria-hidden="true"
/>
<TooltipProvider delayDuration={300}>
<Tooltip>
<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}
<p className="p-1">

30
src/components/generic/Table/index.tsx

@ -1,10 +1,19 @@
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { useState } 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 {
headings: Heading[];
rows: React.ReactNode[][];
rows: React.ReactElement<AvatarCellProps>[][];
}
export interface Heading {
@ -61,6 +70,13 @@ export const Table = ({ headings, rows }: TableProps) => {
const elementA = getElement(a[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") {
const aTimestamp = elementA?.props?.children?.props?.timestamp ?? 0;
const bTimestamp = elementB?.props?.children?.props?.timestamp ?? 0;
@ -142,13 +158,17 @@ export const Table = ({ headings, rows }: TableProps) => {
: null;
const rowKey = firstCellKey ?? Math.random().toString(); // Use random only as last resort
const isFavorite = row[0]?.props?.children?.props?.showFavorite ??
false;
return (
<tr
key={rowKey}
className={`
bg-white dark:bg-slate-900
odd:bg-slate-200/40 dark:odd:bg-slate-800/40
`}
className={cn(
"",
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) => {
const cellKey = `${rowKey}_${cellIndex}`;

93
src/core/hooks/useFavoriteNode.test.ts

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

31
src/core/hooks/useFavoriteNode.ts

@ -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 };
}

96
src/core/hooks/useIgnoreNode.test.ts

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

31
src/core/hooks/useIgnoreNode.ts

@ -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 };
}

59
src/core/stores/deviceStore.ts

@ -1,4 +1,4 @@
import { create } from "@bufbuild/protobuf";
import { create, toBinary } from "@bufbuild/protobuf";
import { MeshDevice, Protobuf, Types } from "@meshtastic/core";
import { produce } from "immer";
import { createContext, useContext } from "react";
@ -93,6 +93,9 @@ export interface Device {
getNodesLength: () => number;
getNode: (nodeNum: number) => Protobuf.Mesh.NodeInfo | undefined;
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 {
@ -594,6 +597,60 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
}
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;
}),
);
},
});
}),
);

4
src/pages/Map/index.tsx

@ -35,7 +35,7 @@ const convertToLatLng = (position: {
});
const MapPage = () => {
const { getNodes, waypoints } = useDevice();
const { getNodes, waypoints, hasNodeError } = useDevice();
const { theme } = useTheme();
const { default: map } = useMap();
@ -146,6 +146,8 @@ const MapPage = () => {
<Avatar
text={node.user?.shortName?.toString() ?? node.num.toString()}
className="border-[1.5px] border-slate-600 shadow-xl shadow-slate-600"
showError={hasNodeError(node.num)}
showFavorite={node.isFavorite}
/>
</Marker>
);

7
src/pages/Messages.tsx

@ -60,7 +60,11 @@ export const MessagesPage = () => {
...node,
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());
@ -239,6 +243,7 @@ export const MessagesPage = () => {
text={node.user?.shortName ?? "UNK"}
className={cn(hasNodeError(node.num) && "text-red-500")}
showError={hasNodeError(node.num)}
showFavorite={node.isFavorite}
size="sm"
/>
</SidebarButton>

15
src/pages/Nodes.tsx

@ -1,5 +1,5 @@
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 { Sidebar } from "@components/Sidebar.tsx";
import { Avatar } from "@components/UI/Avatar.tsx";
@ -27,7 +27,7 @@ export interface DeleteNoteDialogProps {
}
const NodesPage = (): JSX.Element => {
const { getNodes, hardware, connection } = useDevice();
const { getNodes, hardware, connection, hasNodeError } = useDevice();
const [selectedNode, setSelectedNode] = useState<
Protobuf.Mesh.NodeInfo | undefined
>(undefined);
@ -35,7 +35,7 @@ const NodesPage = (): JSX.Element => {
Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery> | undefined
>();
const [selectedLocation, setSelectedLocation] = useState<
Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery> | undefined
Types.PacketMetadata<Protobuf.Mesh.Position> | undefined
>();
const [searchTerm, setSearchTerm] = useState<string>("");
const deferredSearch = useDeferredValue(searchTerm);
@ -84,7 +84,6 @@ const NodesPage = (): JSX.Element => {
<PageLayout
label=""
leftBar={<Sidebar />}
className="flex flex-col w-full"
>
<div className="p-2">
<Input
@ -109,7 +108,11 @@ const NodesPage = (): JSX.Element => {
]}
rows={filteredNodes.map((node) => [
<div key={node.num}>
<Avatar text={node.user?.shortName ?? "UNK "} />
<Avatar
text={node.user?.shortName ?? "UNK "}
showFavorite={node.isFavorite}
showError={hasNodeError(node.num)}
/>
</div>,
<h1
key="longName"
@ -159,7 +162,7 @@ const NodesPage = (): JSX.Element => {
</Mono>,
])}
/>
<NodeOptionsDialog
<NodeDetailsDialog
node={selectedNode}
open={!!selectedNode}
onOpenChange={() => setSelectedNode(undefined)}

Loading…
Cancel
Save