From 513a285fee5eed9f1f0e1d576475d4615e140f66 Mon Sep 17 00:00:00 2001 From: philon- Date: Tue, 13 May 2025 14:45:24 +0200 Subject: [PATCH] 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 --- .../NodeDetailsDialog.test.tsx | 81 ++--- .../NodeDetailsDialog/NodeDetailsDialog.tsx | 311 ++++++++++++++---- src/components/Dialog/NodeOptionsDialog.tsx | 120 ------- .../PageComponents/Messages/MessageItem.tsx | 17 +- src/components/Sidebar.tsx | 12 +- src/components/UI/Avatar.tsx | 50 ++- src/components/generic/Table/index.tsx | 30 +- src/core/hooks/useFavoriteNode.test.ts | 93 ++++++ src/core/hooks/useFavoriteNode.ts | 31 ++ src/core/hooks/useIgnoreNode.test.ts | 96 ++++++ src/core/hooks/useIgnoreNode.ts | 31 ++ src/core/stores/deviceStore.ts | 59 +++- src/pages/Map/index.tsx | 4 +- src/pages/Messages.tsx | 7 +- src/pages/Nodes.tsx | 15 +- 15 files changed, 700 insertions(+), 257 deletions(-) delete mode 100644 src/components/Dialog/NodeOptionsDialog.tsx create mode 100644 src/core/hooks/useFavoriteNode.test.ts create mode 100644 src/core/hooks/useFavoriteNode.ts create mode 100644 src/core/hooks/useIgnoreNode.test.ts create mode 100644 src/core/hooks/useIgnoreNode.ts diff --git a/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.test.tsx b/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.test.tsx index e1f3ca78..ebd33615 100644 --- a/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.test.tsx +++ b/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( {}} />); + render( {}} />); 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( - {}} />, + {}} />, ); 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( {}} />); + const nodeWithoutPosition = { ...mockNode, position: undefined }; + + render( + {}} + />, + ); 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( {}} />); + const nodeWithoutMetrics = { ...mockNode, deviceMetrics: undefined }; + + render( + {}} + />, + ); 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( {}} />); + render( + {}} />, + ); expect(screen.getByText(/Last Heard: Never/i)).toBeInTheDocument(); }); diff --git a/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx b/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx index 33a45945..1ac7296a 100644 --- a/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx +++ b/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(false); + + const { updateIgnored } = useIgnoreNode(); + const [isIgnoredState, setIsIgnoredState] = useState(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 = ({ - Node Details for {device.user?.longName ?? "UNKNOWN"} ( - {device.user?.shortName ?? "UNK"}) + Node Details for {node.user?.longName ?? "UNKNOWN"} ( + {node.user?.shortName ?? "UNK"})
-
- -
-

Details:

-

- Hardware:{" "} - {Protobuf.Mesh.HardwareModel[device.user?.hwModel ?? 0]} -

-

Node Number: {device.num}

-

Node Hex: !{numberToHexUnpadded(device.num)}

-

- Role: {Protobuf.Config.Config_DeviceConfig_Role[ - device.user?.role ?? 0 - ]} -

-

- Last Heard: {device.lastHeard === 0 - ? "Never" - : } -

-
- - {device.position && ( -
-

Position:

- {device.position.latitudeI && device.position.longitudeI && ( -

- Coordinates:{" "} - - {device.position.latitudeI / 1e7},{" "} - {device.position.longitudeI / 1e7} - -

- )} - {device.position.altitude && ( -

Altitude: {device.position.altitude}m

+
+ + + +
+ + + + + + + + {isIgnoredState ? "Unignore node" : "Ignore node"} + + + + + + + + + + + + Remove node + + + + +
+ + + +
+
+
+

Details:

+

Node Number: {node.num}

+

Node Hex: !{numberToHexUnpadded(node.num)}

+

+ Role: {Protobuf.Config.Config_DeviceConfig_Role[ + node.user?.role ?? 0 + ].replace(/_/g, " ")} +

+

+ Last Heard: {node.lastHeard === 0 + ? "Never" + : } +

+

+ Hardware:{" "} + {(Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0] ?? + "Unknown") + .replace(/_/g, " ")} +

- )} + +
+
+ +
+
+

Position:

+ + {node.position + ? ( + <> + {node.position.latitudeI && + node.position.longitudeI && ( +

+ Coordinates:{" "} + + {node.position.latitudeI / 1e7},{" "} + {node.position.longitudeI / 1e7} + +

+ )} + {node.position.altitude && ( +

Altitude: {node.position.altitude}m

+ )} + + ) + :

Unknown

} + +
- {device.deviceMetrics && ( + {node.deviceMetrics && (

Device Metrics: @@ -139,10 +316,10 @@ export const NodeDetailsDialog = ({

), )} - {device.deviceMetrics.uptimeSeconds && ( + {node.deviceMetrics.uptimeSeconds && (

Uptime:{" "} - +

)}
@@ -159,7 +336,7 @@ export const NodeDetailsDialog = ({
-                      {JSON.stringify(device, null, 2)}
+                      {JSON.stringify(node, null, 2)}
                     
diff --git a/src/components/Dialog/NodeOptionsDialog.tsx b/src/components/Dialog/NodeOptionsDialog.tsx deleted file mode 100644 index 04809134..00000000 --- a/src/components/Dialog/NodeOptionsDialog.tsx +++ /dev/null @@ -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 ( - - - - - {`${longName} (${shortName})`} - -
-
- -
-
- -
-
- -
-
- -
-
- -
-
-
-
- ); -}; diff --git a/src/components/PageComponents/Messages/MessageItem.tsx b/src/components/PageComponents/Messages/MessageItem.tsx index d90579e8..1892c057 100644 --- a/src/components/PageComponents/Messages/MessageItem.tsx +++ b/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 (
  • - +
    diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index ec41c293..305b591f 100644 --- a/src/components/Sidebar.tsx +++ b/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) => ( { diff --git a/src/components/UI/Avatar.tsx b/src/components/UI/Avatar.tsx index a1a32186..90a0f20e 100644 --- a/src/components/UI/Avatar.tsx +++ b/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 + ? ( + + + + + + Favorite + + + + + ) + : null} {showError ? ( -