From ca1244107eb10108a897f8b828557df3309f1fc7 Mon Sep 17 00:00:00 2001 From: philon- Date: Sun, 11 May 2025 01:20:46 +0200 Subject: [PATCH] Save isFavorite and isIgnored to device --- .../NodeDetailsDialog.test.tsx | 81 ++++++++----------- .../NodeDetailsDialog/NodeDetailsDialog.tsx | 60 +++++++++----- .../PageComponents/Messages/MessageItem.tsx | 2 +- src/components/UI/Avatar.tsx | 8 +- src/core/hooks/useUpdateFavorite.ts | 30 +++++++ src/core/hooks/useUpdateIgnored.ts | 30 +++++++ src/core/stores/deviceStore.ts | 57 +++++++++++-- src/pages/Map/index.tsx | 2 +- src/pages/Messages.tsx | 2 +- 9 files changed, 191 insertions(+), 81 deletions(-) create mode 100644 src/core/hooks/useUpdateFavorite.ts create mode 100644 src/core/hooks/useUpdateIgnored.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 3f2fa454..9510a4bb 100644 --- a/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx +++ b/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx @@ -12,6 +12,9 @@ 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 { useUpdateFavorite } from "../../../core/hooks/useUpdateFavorite.ts"; +import { useUpdateIgnored } from "../../../core/hooks/useUpdateIgnored.ts"; +import { cn } from "@core/utils/cn.ts"; import { Accordion, @@ -30,8 +33,8 @@ import { import { Button } from "@components/UI/Button.tsx"; import { - EarIcon, - EarOffIcon, + BellIcon, + BellOffIcon, MapPinnedIcon, MessageSquareIcon, StarIcon, @@ -58,20 +61,24 @@ export const NodeDetailsDialog = ({ open, onOpenChange, }: NodeDetailsDialogProps) => { - const { setDialogOpen, connection, setActivePage, setFavorite } = useDevice(); + const { setDialogOpen, connection, setActivePage } = useDevice(); const { setNodeNumToBeRemoved } = useAppStore(); const { setChatType, setActiveChat } = useMessageStore(); - const [isFavouriteState, setIsFavoriteState] = useState(false); + const { updateFavorite } = useUpdateFavorite(); + const [isFavoriteState, setIsFavoriteState] = useState(false); + const { updateIgnored } = useUpdateIgnored(); + const [isIgnoredState, setIsIgnoredState] = useState(false); useEffect(() => { if (!node) return; setIsFavoriteState(node?.isFavorite); + setIsIgnoredState(node?.isIgnored); }, [node]); - if (!node) return null; + if (!node) return; function handleDirectMessage() { - if (!node) return null; + if (!node) return; setChatType(MessageType.Direct); setActiveChat(node.num); @@ -79,7 +86,7 @@ export const NodeDetailsDialog = ({ } function handleRequestPosition() { - if (!node) return null; + if (!node) return; toast({ title: "Requesting position, please wait...", @@ -93,7 +100,7 @@ export const NodeDetailsDialog = ({ } function handleTraceroute() { - if (!node) return null; + if (!node) return; toast({ title: "Sending Traceroute, please wait...", @@ -107,17 +114,25 @@ export const NodeDetailsDialog = ({ } function handleNodeRemove() { - if (!node) return null; + if (!node) return; setNodeNumToBeRemoved(node?.num); setDialogOpen("nodeRemoval", true); onOpenChange(false); } - function handleToggleFavourite() { - if (!node) return null; - setFavorite(node.num, !isFavouriteState); - setIsFavoriteState(!isFavouriteState); + 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 = [ @@ -168,9 +183,9 @@ export const NodeDetailsDialog = ({ Trace Route -
@@ -179,14 +194,19 @@ export const NodeDetailsDialog = ({ - {node.isIgnored ? "Unignore node" : "Ignore node"} + {isIgnoredState ? "Unignore node" : "Ignore node"} @@ -273,7 +293,7 @@ export const NodeDetailsDialog = ({ ) :

Unknown

} - diff --git a/src/components/PageComponents/Messages/MessageItem.tsx b/src/components/PageComponents/Messages/MessageItem.tsx index 056002c4..1892c057 100644 --- a/src/components/PageComponents/Messages/MessageItem.tsx +++ b/src/components/PageComponents/Messages/MessageItem.tsx @@ -150,7 +150,7 @@ export const MessageItem = ({ message }: MessageItemProps) => { size="sm" text={shortName} className="pt-0.5" - showFavourite={isFavorite} + showFavorite={isFavorite} />
diff --git a/src/components/UI/Avatar.tsx b/src/components/UI/Avatar.tsx index 7450e421..48e96e90 100644 --- a/src/components/UI/Avatar.tsx +++ b/src/components/UI/Avatar.tsx @@ -20,7 +20,7 @@ interface AvatarProps { size?: "sm" | "lg"; className?: string; showError?: boolean; - showFavourite?: boolean; + showFavorite?: boolean; } class ColorUtils { @@ -70,7 +70,7 @@ export const Avatar = ({ text, size = "sm", showError = false, - showFavourite = false, + showFavorite = false, className, }: AvatarProps) => { const sizes = { @@ -97,7 +97,7 @@ export const Avatar = ({ color: textColor, }} > - {showFavourite + {showFavorite ? ( @@ -111,7 +111,7 @@ export const Avatar = ({ /> - Favourite + Favorite diff --git a/src/core/hooks/useUpdateFavorite.ts b/src/core/hooks/useUpdateFavorite.ts new file mode 100644 index 00000000..36b601d8 --- /dev/null +++ b/src/core/hooks/useUpdateFavorite.ts @@ -0,0 +1,30 @@ +import { useCallback } from "react"; +import { useDevice } from "@core/stores/deviceStore.ts"; +import { useToast } from "@core/hooks/useToast.ts"; + +interface UpdateFavoriteOptions { + nodeNum: number; + isFavorite: boolean; +} + +export function useUpdateFavorite() { + const { updateFavorite, getNode } = useDevice(); + const { toast } = useToast(); + + const updateFavoriteCB = useCallback( + ({ nodeNum, isFavorite }: UpdateFavoriteOptions) => { + updateFavorite(nodeNum, isFavorite); + + const node = getNode(nodeNum); + + toast({ + title: `${isFavorite ? "Added" : "Removed"} ${ + node?.user?.longName ?? "node" + } ${isFavorite ? "to" : "from"} favorites`, + }); + }, + [updateFavorite, getNode], + ); + + return { updateFavorite: updateFavoriteCB }; +} diff --git a/src/core/hooks/useUpdateIgnored.ts b/src/core/hooks/useUpdateIgnored.ts new file mode 100644 index 00000000..0a2e1ce5 --- /dev/null +++ b/src/core/hooks/useUpdateIgnored.ts @@ -0,0 +1,30 @@ +import { useCallback } from "react"; +import { useDevice } from "@core/stores/deviceStore.ts"; +import { useToast } from "@core/hooks/useToast.ts"; + +interface UpdateIgnoredOptions { + nodeNum: number; + isIgnored: boolean; +} + +export function useUpdateIgnored() { + const { updateIgnored, getNode } = useDevice(); + const { toast } = useToast(); + + const updateIgnoredCB = useCallback( + ({ nodeNum, isIgnored }: UpdateIgnoredOptions) => { + updateIgnored(nodeNum, isIgnored); + + const node = getNode(nodeNum); + + toast({ + title: `${isIgnored ? "Added" : "Removed"} ${ + node?.user?.longName ?? "node" + } ${isIgnored ? "to" : "from"} ignore list`, + }); + }, + [updateIgnored, getNode], + ); + + return { updateIgnored: updateIgnoredCB }; +} diff --git a/src/core/stores/deviceStore.ts b/src/core/stores/deviceStore.ts index e6cc35fa..e2f1ffe8 100644 --- a/src/core/stores/deviceStore.ts +++ b/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,7 +93,9 @@ export interface Device { getNodesLength: () => number; getNode: (nodeNum: number) => Protobuf.Mesh.NodeInfo | undefined; getMyNode: () => Protobuf.Mesh.NodeInfo; - setFavorite: (nodeNum: number, isFavorite: boolean) => void; + sendAdminMessage: (message: Protobuf.Admin.AdminMessage) => void; + updateFavorite: (nodeNum: number, isFavorite: boolean) => void; + updateIgnored: (nodeNum: number, isIgnored: boolean) => void; } export interface DeviceState { @@ -595,16 +597,57 @@ export const useDeviceStore = createStore((set, get) => ({ } return device.nodesMap.size; }, - setFavorite(nodeNum: number, isFavorite: boolean) { + + 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((draft) => { const device = draft.devices.get(id); - if (!device) throw new Error(`Device ${id} not found`); + 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; - const node = device.nodesMap.get(nodeNum); - if (!node) throw new Error(`Node ${nodeNum} not found`); + device.sendAdminMessage(create(Protobuf.Admin.AdminMessageSchema, { + payloadVariant: { + case: isIgnored ? "setIgnoredNode" : "removeIgnoredNode", + value: nodeNum, + }, + })); - node.isFavorite = isFavorite; + set( + produce((draft) => { + const device = draft.devices.get(id); + const node = device?.nodesMap.get(nodeNum); + node.isIgnored = isIgnored; }), ); }, diff --git a/src/pages/Map/index.tsx b/src/pages/Map/index.tsx index 777837da..d08451b7 100644 --- a/src/pages/Map/index.tsx +++ b/src/pages/Map/index.tsx @@ -147,7 +147,7 @@ const MapPage = () => { text={node.user?.shortName?.toString() ?? node.num.toString()} className="border-[1.5px] border-slate-600 shadow-xl shadow-slate-600" showError={hasNodeError(node.num)} - showFavourite={node.isFavorite} + showFavorite={node.isFavorite} /> ); diff --git a/src/pages/Messages.tsx b/src/pages/Messages.tsx index 37a482c9..e8146d3f 100644 --- a/src/pages/Messages.tsx +++ b/src/pages/Messages.tsx @@ -239,7 +239,7 @@ export const MessagesPage = () => { text={node.user?.shortName ?? "UNK"} className={cn(hasNodeError(node.num) && "text-red-500")} showError={hasNodeError(node.num)} - showFavourite={node.isFavorite} + showFavorite={node.isFavorite} size="sm" />