Browse Source
* 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 betterpull/620/head v2.6.4
committed by
GitHub
15 changed files with 700 additions and 257 deletions
@ -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> |
|
||||
); |
|
||||
}; |
|
||||
@ -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 }; |
||||
|
} |
||||
Loading…
Reference in new issue