Browse Source

Save isFavorite and isIgnored to device

pull/618/head
philon- 1 year ago
parent
commit
ca1244107e
  1. 81
      src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.test.tsx
  2. 60
      src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx
  3. 2
      src/components/PageComponents/Messages/MessageItem.tsx
  4. 8
      src/components/UI/Avatar.tsx
  5. 30
      src/core/hooks/useUpdateFavorite.ts
  6. 30
      src/core/hooks/useUpdateIgnored.ts
  7. 57
      src/core/stores/deviceStore.ts
  8. 2
      src/pages/Map/index.tsx
  9. 2
      src/pages/Messages.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();
});

60
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<boolean>(false);
const { updateFavorite } = useUpdateFavorite();
const [isFavoriteState, setIsFavoriteState] = useState<boolean>(false);
const { updateIgnored } = useUpdateIgnored();
const [isIgnoredState, setIsIgnoredState] = useState<boolean>(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 = ({
<WaypointsIcon className="mr-2" />
Trace Route
</Button>
<Button className="mr-1" onClick={handleToggleFavourite}>
<Button className="mr-1" onClick={handleToggleFavorite}>
<StarIcon
className={isFavouriteState ? " fill-yellow-400" : ""}
className={isFavoriteState ? " fill-yellow-400" : ""}
/>
</Button>
<div className="flex flex-1 justify-start"></div>
@ -179,14 +194,19 @@ export const NodeDetailsDialog = ({
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={node.isIgnored ? "default" : "destructive"}
className="flex justify-end mr-1"
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}
>
{node.isIgnored ? <EarIcon /> : <EarOffIcon />}
{isIgnoredState ? <BellIcon /> : <BellOffIcon />}
</Button>
</TooltipTrigger>
<TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs">
{node.isIgnored ? "Unignore node" : "Ignore node"}
{isIgnoredState ? "Unignore node" : "Ignore node"}
<TooltipArrow className="fill-slate-800 dark:fill-slate-600" />
</TooltipContent>
</Tooltip>
@ -273,7 +293,7 @@ export const NodeDetailsDialog = ({
</>
)
: <p>Unknown</p>}
<Button onClick={handleRequestPosition}>
<Button onClick={handleRequestPosition} className="mt-2">
<MapPinnedIcon className="mr-2" />
Request Position
</Button>

2
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}
/>
<div className="flex flex-col gap-0.5 min-w-0">

8
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
? (
<TooltipProvider delayDuration={300}>
<Tooltip>
@ -111,7 +111,7 @@ export const Avatar = ({
/>
</TooltipTrigger>
<TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs">
Favourite
Favorite
<TooltipArrow className="fill-slate-800" />
</TooltipContent>
</Tooltip>

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

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

57
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<PrivateDeviceState>((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<DeviceState>((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<DeviceState>((draft) => {
const device = draft.devices.get(id);
const node = device?.nodesMap.get(nodeNum);
node.isIgnored = isIgnored;
}),
);
},

2
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}
/>
</Marker>
);

2
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"
/>
</SidebarButton>

Loading…
Cancel
Save