Browse Source

added footer, fixed tests. styling

pull/586/head
Dan Ditomaso 1 year ago
parent
commit
71a3559559
  1. 97
      src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.test.tsx
  2. 157
      src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.test.tsx
  3. 7
      src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx
  4. 17
      src/components/PageComponents/Messages/ChannelChat.tsx
  5. 20
      src/components/PageComponents/Messages/MessageInput.tsx
  6. 45
      src/components/PageComponents/Messages/TraceRoute.test.tsx
  7. 2
      src/components/PageComponents/Messages/TraceRoute.tsx
  8. 4
      src/components/PageLayout.tsx
  9. 7
      src/components/Sidebar.tsx
  10. 2
      src/components/ThemeSwitcher.tsx
  11. 76
      src/components/UI/Input.tsx
  12. 6
      src/components/UI/Sidebar/sidebarButton.tsx
  13. 22
      src/core/stores/messageStore.ts
  14. 4
      src/core/subscriptions.ts
  15. 1
      src/pages/Config/index.tsx
  16. 16
      src/pages/Messages.tsx
  17. 14
      src/pages/Nodes.tsx

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

@ -1,12 +1,17 @@
import { describe, it, vi, expect, beforeEach, Mock } from "vitest"; import { describe, it, vi, expect, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx"; import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import { useAppStore } from "@core/stores/appStore.ts"; import { useAppStore } from "@core/stores/appStore.ts";
import { Protobuf } from "@meshtastic/core";
vi.mock("@core/stores/deviceStore"); vi.mock("@core/stores/deviceStore");
vi.mock("@core/stores/appStore"); vi.mock("@core/stores/appStore");
const mockUseDevice = vi.mocked(useDevice);
const mockUseAppStore = vi.mocked(useAppStore);
describe("NodeDetailsDialog", () => { describe("NodeDetailsDialog", () => {
const mockDevice = { const mockDevice = {
num: 1234, num: 1234,
@ -29,17 +34,21 @@ describe("NodeDetailsDialog", () => {
voltage: 4.2, voltage: 4.2,
uptimeSeconds: 3600, uptimeSeconds: 3600,
}, },
}; } as unknown as Protobuf.Mesh.NodeInfo;
beforeEach(() => { beforeEach(() => {
// Reset mocks before each test
vi.resetAllMocks(); vi.resetAllMocks();
(useDevice as Mock).mockReturnValue({ mockUseDevice.mockReturnValue({
nodes: new Map([[1234, mockDevice]]), getNode: (nodeNum: number) => {
if (nodeNum === 1234) {
return mockDevice;
}
return undefined;
},
}); });
(useAppStore as unknown as Mock).mockReturnValue({ mockUseAppStore.mockReturnValue({
nodeNumDetails: 1234, nodeNumDetails: 1234,
}); });
}); });
@ -47,27 +56,87 @@ describe("NodeDetailsDialog", () => {
it("renders node details correctly", () => { it("renders node details correctly", () => {
render(<NodeDetailsDialog open={true} onOpenChange={() => { }} />); render(<NodeDetailsDialog open={true} onOpenChange={() => { }} />);
expect(screen.getByText(/Node Details for Test Node/i)).toBeInTheDocument(); expect(screen.getByText(/Node Details for Test Node \(TN\)/i)).toBeInTheDocument();
expect(screen.getByText("Node Number: 1234")).toBeInTheDocument(); expect(screen.getByText("Node Number: 1234")).toBeInTheDocument();
expect(screen.getByText(/Node Hex: !/i)).toBeInTheDocument();
expect(screen.getByText(/Last Heard:/i)).toBeInTheDocument();
expect(screen.getByText(/Coordinates:/i)).toBeInTheDocument();
const link = screen.getByRole('link', { name: /^45, -75$/ });
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute('href', expect.stringContaining('openstreetmap.org'));
expect(screen.getByText(/Altitude: 200m/i)).toBeInTheDocument();
expect(screen.getByText(/Air TX utilization: 50.12%/i)).toBeInTheDocument(); expect(screen.getByText(/Air TX utilization: 50.12%/i)).toBeInTheDocument();
expect(screen.getByText(/Channel utilization: 75.46%/i)).toBeInTheDocument(); expect(screen.getByText(/Channel utilization: 75.46%/i)).toBeInTheDocument();
expect(screen.getByText(/Battery level: 88.79%/i)).toBeInTheDocument(); expect(screen.getByText(/Battery level: 88.79%/i)).toBeInTheDocument();
expect(screen.getByText(/Voltage: 4.20V/i)).toBeInTheDocument(); expect(screen.getByText(/Voltage: 4.20V/i)).toBeInTheDocument();
expect(screen.getByText(/Uptime:/i)).toBeInTheDocument(); expect(screen.getByText(/Uptime:/i)).toBeInTheDocument();
expect(screen.getByText(/Coordinates:/i)).toBeInTheDocument();
expect(screen.getByText("45, -75")).toBeInTheDocument(); expect(screen.getByText(/All Raw Metrics:/i)).toBeInTheDocument();
expect(screen.getByText(/Altitude: 200m/i)).toBeInTheDocument();
expect(screen.getByText(/Role:/i)).toBeInTheDocument();
}); });
it("renders null if device is not found", () => { it("renders null if device is not found", () => {
(useDevice as Mock).mockReturnValue({ const requestedNodeNum = 5678;
nodes: new Map(),
mockUseAppStore.mockReturnValue({
nodeNumDetails: requestedNodeNum,
}); });
render(<NodeDetailsDialog open={true} onOpenChange={() => { }} />); mockUseDevice.mockReturnValue({
getNode: (nodeNum: number) => {
if (nodeNum === requestedNodeNum) {
return undefined;
}
if (nodeNum === 1234) {
return mockDevice;
}
return undefined;
},
});
const { container } = render(<NodeDetailsDialog open={true} onOpenChange={() => { }} />);
expect(container.firstChild).toBeNull();
expect(screen.queryByText(/Node Details for/i)).not.toBeInTheDocument(); expect(screen.queryByText(/Node Details for/i)).not.toBeInTheDocument();
}); });
it("renders correctly when position is missing", () => {
const nodeWithoutPosition = { ...mockDevice, position: undefined };
mockUseDevice.mockReturnValue({ getNode: () => nodeWithoutPosition });
mockUseAppStore.mockReturnValue({ nodeNumDetails: 1234 });
render(<NodeDetailsDialog open={true} onOpenChange={() => { }} />);
expect(screen.queryByText(/Coordinates:/i)).not.toBeInTheDocument();
expect(screen.queryByText(/Altitude:/i)).not.toBeInTheDocument();
expect(screen.getByText(/Node Details for Test Node/i)).toBeInTheDocument();
});
it("renders correctly when deviceMetrics are missing", () => {
const nodeWithoutMetrics = { ...mockDevice, deviceMetrics: undefined };
mockUseDevice.mockReturnValue({ getNode: () => nodeWithoutMetrics });
mockUseAppStore.mockReturnValue({ nodeNumDetails: 1234 });
render(<NodeDetailsDialog open={true} onOpenChange={() => { }} />);
expect(screen.queryByText(/Device Metrics:/i)).not.toBeInTheDocument();
expect(screen.queryByText(/Air TX utilization:/i)).not.toBeInTheDocument();
expect(screen.getByText(/Node Details for Test Node/i)).toBeInTheDocument();
});
it("renders 'Never' for lastHeard when timestamp is 0", () => {
const nodeNeverHeard = { ...mockDevice, lastHeard: 0 };
mockUseDevice.mockReturnValue({ getNode: () => nodeNeverHeard });
mockUseAppStore.mockReturnValue({ nodeNumDetails: 1234 });
render(<NodeDetailsDialog open={true} onOpenChange={() => { }} />);
expect(screen.getByText(/Last Heard: Never/i)).toBeInTheDocument();
});
}); });

157
src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.test.tsx

@ -1,102 +1,97 @@
import { render, screen, fireEvent } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi, Mock } from "vitest"; import { DeviceContext, useDeviceStore } from "@core/stores/deviceStore.ts";
import { RefreshKeysDialog } from "./RefreshKeysDialog.tsx"; import { RefreshKeysDialog } from "./RefreshKeysDialog.tsx";
import { useMessageStore } from "@core/stores/messageStore.ts";
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts"; import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
import { useMessageStore } from "@core/stores/messageStore.ts"; // Import for mocking import { expect, test, vi, beforeEach, afterEach } from 'vitest';
import { useDevice } from "@core/stores/deviceStore.ts"; // Import for mocking
import { Protobuf } from "@meshtastic/core"; import { Protobuf } from "@meshtastic/core";
vi.mock("@core/stores/messageStore");
vi.mock("./useRefreshKeysDialog");
vi.mock("@core/stores/messageStore.ts", () => ({ const mockUseMessageStore = vi.mocked(useMessageStore);
useMessageStore: vi.fn(), const mockUseRefreshKeysDialog = vi.mocked(useRefreshKeysDialog);
}));
const mockNodeWithError: Partial<Protobuf.Mesh.NodeInfo> = { const getInitialState = () => useDeviceStore.getInitialState?.() ?? { devices: new Map(), remoteDevices: new Map() };
user: { longName: "Test Node Long", shortName: "TNL", id: 456 },
};
const mockNodes = new Map([[456, mockNodeWithError]]);
const mockNodeErrors = new Map([[123, { node: 456 }]]);
vi.mock("@core/stores/deviceStore.ts", () => ({ beforeEach(() => {
useDevice: vi.fn(), useDeviceStore.setState(getInitialState(), true);
}));
const mockHandleCloseDialog = vi.fn();
const mockHandleNodeRemove = vi.fn();
vi.mock("./useRefreshKeysDialog.ts", () => ({
useRefreshKeysDialog: vi.fn(() => ({
handleCloseDialog: mockHandleCloseDialog,
handleNodeRemove: mockHandleNodeRemove,
})),
}));
describe("RefreshKeysDialog Component", () => {
let onOpenChangeMock: Mock;
beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
onOpenChangeMock = vi.fn(); });
vi.mocked(useMessageStore).mockReturnValue({ activeChat: 123 });
vi.mocked(useDevice).mockReturnValue({
nodeErrors: mockNodeErrors,
nodes: mockNodes,
});
vi.mocked(useRefreshKeysDialog).mockReturnValue({
handleCloseDialog: mockHandleCloseDialog,
handleNodeRemove: mockHandleNodeRemove,
});
});
it("should render the dialog with dynamic content when open and data is available", () => { afterEach(() => {
render(<RefreshKeysDialog open onOpenChange={onOpenChangeMock} />); vi.restoreAllMocks();
});
expect(screen.getByText(`Keys Mismatch - ${mockNodeWithError?.user?.longName}`)).toBeInTheDocument(); test("renders dialog when there is a node error for the active chat", () => {
expect(screen.getByText(new RegExp(`${mockNodeWithError?.user?.longName}.*${mockNodeWithError?.user?.shortName}`))).toBeInTheDocument(); const deviceId = 1;
expect(screen.getByRole('button', { name: /request new keys/i })).toBeInTheDocument(); const nodeWithErrorNum = 12345;
expect(screen.getByRole('button', { name: /dismiss/i })).toBeInTheDocument(); const activeChatNum = nodeWithErrorNum;
expect(screen.getByRole('button', { name: /Close/i })).toBeInTheDocument();
const deviceStore = useDeviceStore.getState().addDevice(deviceId);
deviceStore.addNodeInfo({
num: nodeWithErrorNum,
user: {
id: nodeWithErrorNum.toString(),
publicKey: new Uint8Array(0),
hwModel: Protobuf.Mesh.HardwareModel.HELTEC_V3,
longName: "Problem Node Long",
shortName: "ProbNode",
isLicensed: false,
macaddr: new Uint8Array(0)
},
lastHeard: Date.now() / 1000,
snr: 10
} as Protobuf.Mesh.NodeInfo);
deviceStore.setNodeError(activeChatNum, "PKI_MISMATCH");
const updatedDeviceState = useDeviceStore.getState().getDevice(deviceId);
if (!updatedDeviceState) {
throw new Error("Failed to get updated device state from store for provider");
}
mockUseMessageStore.mockReturnValue({ activeChat: activeChatNum });
const mockHandleClose = vi.fn();
const mockHandleRemove = vi.fn();
mockUseRefreshKeysDialog.mockReturnValue({
handleCloseDialog: mockHandleClose,
handleNodeRemove: mockHandleRemove,
}); });
it("should call handleNodeRemove when 'Request New Keys' button is clicked", () => { render(
render(<RefreshKeysDialog open onOpenChange={onOpenChangeMock} />); <DeviceContext.Provider value={updatedDeviceState}>
fireEvent.click(screen.getByRole('button', { name: /request new keys/i })); <RefreshKeysDialog open onOpenChange={vi.fn()} />
expect(mockHandleNodeRemove).toHaveBeenCalledTimes(1); </DeviceContext.Provider>
}); );
it("should call handleCloseDialog when 'Dismiss' button is clicked", () => { expect(screen.getByText(/Keys Mismatch - Problem Node Long/)).toBeInTheDocument();
render(<RefreshKeysDialog open onOpenChange={onOpenChangeMock} />); expect(screen.getByText(/Your node is unable to send a direct message to node: Problem Node Long \(ProbNode\)/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /dismiss/i })); expect(screen.getByRole("button", { name: "Request New Keys" })).toBeInTheDocument();
expect(mockHandleCloseDialog).toHaveBeenCalledTimes(1); expect(screen.getByRole("button", { name: "Dismiss" })).toBeInTheDocument();
}); });
it("should call handleCloseDialog when the explicit DialogClose button is clicked", () => { test("does not render dialog if no error exists for active chat", () => {
render(<RefreshKeysDialog open onOpenChange={onOpenChangeMock} />); const deviceId = 1;
fireEvent.click(screen.getByRole('button', { name: /close/i })); // Use the aria-label const activeChatNum = 54321;
expect(mockHandleCloseDialog).toHaveBeenCalledTimes(1);
});
useDeviceStore.getState().addDevice(deviceId);
it("should not render the dialog when open is false", () => { const currentDeviceState = useDeviceStore.getState().getDevice(deviceId);
render(<RefreshKeysDialog open={false} onOpenChange={onOpenChangeMock} />); if (!currentDeviceState) throw new Error("Device not found");
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("should render null if nodeErrorNum is not found for activeChat", () => { mockUseMessageStore.mockReturnValue({ activeChat: activeChatNum });
vi.mocked(useDevice).mockReturnValue({ mockUseRefreshKeysDialog.mockReturnValue({
nodeErrors: new Map(), handleCloseDialog: vi.fn(),
nodes: mockNodes, handleNodeRemove: vi.fn(),
});
const { container } = render(<RefreshKeysDialog open onOpenChange={onOpenChangeMock} />);
expect(container.firstChild).toBeNull();
}); });
it("should render null if nodeWithError is not found for nodeErrorNum.node", () => { const { container } = render(
vi.mocked(useDevice).mockReturnValue({ <DeviceContext.Provider value={currentDeviceState}>
nodeErrors: mockNodeErrors, <RefreshKeysDialog open onOpenChange={vi.fn()} />
nodes: new Map(), </DeviceContext.Provider>
}); );
const { container } = render(<RefreshKeysDialog open onOpenChange={onOpenChangeMock} />);
expect(container.firstChild).toBeNull(); expect(container.firstChild).toBeNull();
});
}); });

7
src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx

@ -27,12 +27,7 @@ export const RefreshKeysDialog = ({ open, onOpenChange }: RefreshKeysDialogProps
return null; return null;
} }
const nodeWithError = getNode(nodeErrorNum?.node ?? 0); const nodeWithError = getNode(nodeErrorNum.node);
if (!nodeWithError) {
console.error("Node with error not found");
return null;
}
const text = { const text = {
title: `Keys Mismatch - ${nodeWithError?.user?.longName ?? ""}`, title: `Keys Mismatch - ${nodeWithError?.user?.longName ?? ""}`,

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

@ -8,7 +8,7 @@ export interface ChannelChatProps {
} }
const EmptyState = () => ( const EmptyState = () => (
<div className="flex flex-1 flex-col place-content-center place-items-center p-8 text-gray-500 dark:text-gray-400"> <div className="flex flex-1 flex-col place-content-center place-items-center p-8 text-slate-500 dark:text-slate-400">
<InboxIcon className="mb-2 h-8 w-8" /> <InboxIcon className="mb-2 h-8 w-8" />
<span className="text-sm">No Messages</span> <span className="text-sm">No Messages</span>
</div> </div>
@ -24,9 +24,10 @@ export const ChannelChat = ({
const scrollContainer = scrollContainerRef.current; const scrollContainer = scrollContainerRef.current;
if (!scrollContainer) return; if (!scrollContainer) return;
const isNearBottom = scrollContainer.scrollTop < 100; const scrollThreshold = 50; // How close to bottom to trigger smooth scroll
const isNearBottom = scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight < scrollThreshold;
if (isNearBottom || behavior === 'instant') { if (behavior === 'instant' || isNearBottom) {
messagesEndRef.current?.scrollIntoView({ behavior }); messagesEndRef.current?.scrollIntoView({ behavior });
} }
}, []); }, []);
@ -37,25 +38,21 @@ export const ChannelChat = ({
} }
}, [messages, scrollToBottom]); }, [messages, scrollToBottom]);
useEffect(() => {
if (messages.length > 0) {
scrollToBottom('instant');
}
}, [scrollToBottom, messages.length]);
if (!messages?.length) { if (!messages?.length) {
return ( return (
<ul ref={scrollContainerRef} className="flex flex-1 flex-col items-center justify-center"> <ul ref={scrollContainerRef} className="flex flex-1 flex-col items-center justify-center">
<EmptyState /> <EmptyState />
<div ref={messagesEndRef} />
</ul> </ul>
); );
} }
return ( return (
<ul <ul
ref={scrollContainerRef} ref={scrollContainerRef}
className="flex flex-1 flex-col-reverse overflow-y-auto px-3 py-2" className="mt-auto overflow-y-auto px-3 py-2"
> >
<div ref={messagesEndRef} className="h-px" /> <div ref={messagesEndRef} className="h-px" />
{messages?.map((message) => { {messages?.map((message) => {

20
src/components/PageComponents/Messages/MessageInput.tsx

@ -3,9 +3,11 @@ import { Input } from "@components/UI/Input.tsx";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import type { Types } from "@meshtastic/core"; import type { Types } from "@meshtastic/core";
import { SendIcon } from "lucide-react"; import { SendIcon } from "lucide-react";
import { startTransition, useCallback, useMemo, useState } from "react"; import {
startTransition, useCallback, // @ts-types="react"
useDeferredValue, useState
} from "react";
import { MessageState, MessageType, useMessageStore } from "@core/stores/messageStore.ts"; import { MessageState, MessageType, useMessageStore } from "@core/stores/messageStore.ts";
import { debounce } from "@core/utils/debounce.ts";
export interface MessageInputProps { export interface MessageInputProps {
to: Types.Destination; to: Types.Destination;
@ -24,12 +26,9 @@ export const MessageInput = ({
const [localDraft, setLocalDraft] = useState(getDraft(to)); const [localDraft, setLocalDraft] = useState(getDraft(to));
const [messageBytes, setMessageBytes] = useState(0); const [messageBytes, setMessageBytes] = useState(0);
const debouncedSetMessageDraft = useMemo(
() => debounce((value: string) => setDraft(to, value), 300),
[setDraft, to]
);
const calculateBytes = (text: string) => new Blob([text]).size; const calculateBytes = (text: string) => new Blob([text]).size;
const deferredBytes = useDeferredValue(calculateBytes(localDraft));
const chatType = to === MessageType.Broadcast ? MessageType.Broadcast : MessageType.Direct; const chatType = to === MessageType.Broadcast ? MessageType.Broadcast : MessageType.Direct;
@ -52,12 +51,15 @@ export const MessageInput = ({
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value; const newValue = e.target.value;
const byteLength = calculateBytes(newValue); const byteLength = deferredBytes
if (byteLength <= maxBytes) { if (byteLength <= maxBytes) {
setLocalDraft(newValue); setLocalDraft(newValue);
debouncedSetMessageDraft(newValue);
setMessageBytes(byteLength); setMessageBytes(byteLength);
startTransition(() => {
setDraft(to, newValue);
});
} }
}; };

45
src/components/PageComponents/Messages/TraceRoute.test.tsx

@ -1,30 +1,33 @@
import { describe, it, expect, vi, beforeEach, Mock } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import { TraceRoute } from "@components/PageComponents/Messages/TraceRoute.tsx"; import { TraceRoute } from "@components/PageComponents/Messages/TraceRoute.tsx";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import type { Protobuf } from "@meshtastic/core";
vi.mock("@core/stores/deviceStore"); vi.mock("@core/stores/deviceStore");
describe("TraceRoute", () => { describe("TraceRoute", () => {
const mockNodes = new Map([ const mockNodes = new Map<number, Protobuf.Mesh.NodeInfo>([
[ [
1, 1,
{ num: 1, user: { longName: "Node A" } }, { num: 1, user: { longName: "Node A" } } as Protobuf.Mesh.NodeInfo,
], ],
[ [
2, 2,
{ num: 2, user: { longName: "Node B" } }, { num: 2, user: { longName: "Node B" } } as Protobuf.Mesh.NodeInfo,
], ],
[ [
3, 3,
{ num: 3, user: { longName: "Node C" } }, { num: 3, user: { longName: "Node C" } } as Protobuf.Mesh.NodeInfo,
], ],
]); ]);
beforeEach(() => { beforeEach(() => {
vi.resetAllMocks(); vi.resetAllMocks();
(useDevice as Mock).mockReturnValue({ vi.mocked(useDevice).mockReturnValue({
nodes: mockNodes, getNode: (nodeNum: number): Protobuf.Mesh.NodeInfo | undefined => {
return mockNodes.get(nodeNum);
},
}); });
}); });
@ -38,17 +41,16 @@ describe("TraceRoute", () => {
/> />
); );
expect(screen.getByText("Route to destination:")).toBeInTheDocument(); expect(screen.getAllByText("Source Node")).toHaveLength(1);
expect(screen.getByText("Destination Node")).toBeInTheDocument(); expect(screen.getByText("Destination Node")).toBeInTheDocument();
expect(screen.getByText("Node A")).toBeInTheDocument(); expect(screen.getByText("Node A")).toBeInTheDocument();
expect(screen.getByText("Node B")).toBeInTheDocument(); expect(screen.getByText("Node B")).toBeInTheDocument();
expect(screen.getAllByText(/↓/)).toHaveLength(3); // startNode + 2 hops expect(screen.getAllByText(/↓/)).toHaveLength(3);
expect(screen.getByText("↓ 10dB")).toBeInTheDocument(); expect(screen.getByText("↓ 10dB")).toBeInTheDocument();
expect(screen.getByText("↓ 20dB")).toBeInTheDocument(); expect(screen.getByText("↓ 20dB")).toBeInTheDocument();
expect(screen.getByText("↓ 30dB")).toBeInTheDocument(); expect(screen.getByText("↓ 30dB")).toBeInTheDocument();
expect(screen.getByText("Source Node")).toBeInTheDocument();
}); });
it("renders the route back when provided", () => { it("renders the route back when provided", () => {
@ -64,9 +66,20 @@ describe("TraceRoute", () => {
); );
expect(screen.getByText("Route back:")).toBeInTheDocument(); expect(screen.getByText("Route back:")).toBeInTheDocument();
expect(screen.getAllByText("Source Node")).toHaveLength(2);
expect(screen.getAllByText("Destination Node")).toHaveLength(2);
expect(screen.getByText("Node C")).toBeInTheDocument(); expect(screen.getByText("Node C")).toBeInTheDocument();
expect(screen.getByText("Node A")).toBeInTheDocument();
expect(screen.getByText("↓ 35dB")).toBeInTheDocument(); expect(screen.getByText("↓ 35dB")).toBeInTheDocument();
expect(screen.getByText("↓ 45dB")).toBeInTheDocument(); expect(screen.getByText("↓ 45dB")).toBeInTheDocument();
expect(screen.getByText("↓ 15dB")).toBeInTheDocument();
expect(screen.getByText("↓ 25dB")).toBeInTheDocument();
}); });
it("renders '??' for missing SNR values", () => { it("renders '??' for missing SNR values", () => {
@ -78,18 +91,22 @@ describe("TraceRoute", () => {
/> />
); );
expect(screen.getAllByText("↓ ??dB").length).toBeGreaterThan(0); expect(screen.getByText("Node A")).toBeInTheDocument();
expect(screen.getAllByText("↓ ??dB")).toHaveLength(2);
}); });
it("renders hop hex if node is not found", () => { it("renders hop hex if node is not found", () => {
render( render(
<TraceRoute <TraceRoute
from={{ user: { longName: "Source" } } as any} from={{ user: { longName: "Source" } } as unknown}
to={{ user: { longName: "Dest" } } as any} to={{ user: { longName: "Dest" } } as unknown}
route={[99]} route={[99]}
snrTowards={[5, 15]}
/> />
); );
expect(screen.getByText(/^!63$/)).toBeInTheDocument(); // 99 in hex expect(screen.getByText(/^!63$/)).toBeInTheDocument();
expect(screen.getByText("↓ 5dB")).toBeInTheDocument();
expect(screen.getByText("↓ 15dB")).toBeInTheDocument();
}); });
}); });

2
src/components/PageComponents/Messages/TraceRoute.tsx

@ -23,7 +23,7 @@ const RoutePath = ({ title, startNode, endNode, path, snr }: RoutePathProps) =>
const { getNode } = useDevice(); const { getNode } = useDevice();
return ( return (
<span className="ml-4 border-l-2 border-l-background-primary pl-2 text-slate-900 dark:text-slate-900"> <span id={title} className="ml-4 border-l-2 border-l-background-primary pl-2 text-slate-900 dark:text-slate-900">
<p className="font-semibold">{title}</p> <p className="font-semibold">{title}</p>
<p>{startNode?.user?.longName}</p> <p>{startNode?.user?.longName}</p>
<p> {snr?.[0] ?? "??"}dB</p> <p> {snr?.[0] ?? "??"}dB</p>

4
src/components/PageLayout.tsx

@ -105,14 +105,14 @@ export const PageLayout = ({
> >
{children} {children}
</main> </main>
{/* <Footer /> */} <Footer />
</div> </div>
{/* Right Sidebar */} {/* Right Sidebar */}
{rightBar && ( {rightBar && (
<aside <aside
className={cn( className={cn(
"max-w-[288px] shrink-0 border-l border-slate-300 dark:border-slate-700 p-2 overflow-hidden", "w-76 max-w-76 shrink-0 border-l border-slate-300 dark:border-slate-700 p-2 overflow-hidden",
rightBarClassName rightBarClassName
)} )}
> >

7
src/components/Sidebar.tsx

@ -47,8 +47,9 @@ const CollapseToggleButton = () => {
onClick={toggleSidebar} onClick={toggleSidebar}
className={cn( className={cn(
'absolute top-21 -right-2 z-10 p-0.5 rounded-full transform translate-x-1/2', 'absolute top-21 -right-2 z-10 p-0.5 rounded-full transform translate-x-1/2',
'border border-slate-300 dark:border-slate-700', 'transition-colors duration-300 ease-in-out',
'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-100', 'border border-slate-300 dark:border-slate-200',
'text-slate-500 dark:text-slate-200 hover:text-slate-400 dark:hover:text-slate-400',
'focus:outline-none focus:ring-2 focus:ring-accent transition-transform' 'focus:outline-none focus:ring-2 focus:ring-accent transition-transform'
)} )}
> >
@ -185,7 +186,7 @@ export const Sidebar = ({ children }: SidebarProps) => {
/> />
<p <p
className={cn( className={cn(
'max-w-[20ch] whitespace-wrap text-sm font-medium', 'max-w-[20ch] text-wrap text-sm font-medium',
'transition-all duration-300 ease-in-out overflow-hidden', 'transition-all duration-300 ease-in-out overflow-hidden',
isCollapsed isCollapsed
? 'opacity-0 max-w-0 invisible' ? 'opacity-0 max-w-0 invisible'

2
src/components/ThemeSwitcher.tsx

@ -34,7 +34,7 @@ export default function ThemeSwitcher({
className, className,
)} )}
onClick={toggleTheme} onClick={toggleTheme}
aria-description={"Change current theme"} aria-description="Change current theme"
> >
<span <span
data-label data-label

76
src/components/UI/Input.tsx

@ -1,7 +1,7 @@
import * as React from "react"; import * as React from "react";
import { cn } from "@core/utils/cn.ts"; import { cn } from "@core/utils/cn.ts";
import { cva, type VariantProps } from "class-variance-authority"; import { cva, type VariantProps } from "class-variance-authority";
import { Check, Copy, Eye, EyeOff, type LucideIcon } from "lucide-react"; import { Check, Copy, Eye, EyeOff, X, type LucideIcon } from "lucide-react";
import { useCopyToClipboard } from "@core/hooks/useCopyToClipboard.ts"; import { useCopyToClipboard } from "@core/hooks/useCopyToClipboard.ts";
import { usePasswordVisibilityToggle } from "@core/hooks/usePasswordVisibilityToggle.ts"; import { usePasswordVisibilityToggle } from "@core/hooks/usePasswordVisibilityToggle.ts";
@ -27,6 +27,7 @@ type InputActionType = {
onClick: (e: React.MouseEvent<HTMLButtonElement>) => void; onClick: (e: React.MouseEvent<HTMLButtonElement>) => void;
ariaLabel: string; ariaLabel: string;
tooltip?: string; tooltip?: string;
condition?: boolean;
}; };
export interface InputProps export interface InputProps
@ -36,6 +37,7 @@ export interface InputProps
suffix?: React.ReactNode; suffix?: React.ReactNode;
showPasswordToggle?: boolean; showPasswordToggle?: boolean;
showCopyButton?: boolean; showCopyButton?: boolean;
showClearButton?: boolean;
containerClassName?: string; containerClassName?: string;
} }
@ -50,7 +52,9 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
suffix, suffix,
showPasswordToggle, showPasswordToggle,
showCopyButton, showCopyButton,
showClearButton,
value, value,
onChange,
...props ...props
}, },
ref ref
@ -58,10 +62,28 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
const { isVisible, toggleVisibility } = usePasswordVisibilityToggle(); const { isVisible, toggleVisibility } = usePasswordVisibilityToggle();
const { copy, isCopied } = useCopyToClipboard({ timeout: 1500 }); const { copy, isCopied } = useCopyToClipboard({ timeout: 1500 });
const actions: InputActionType[] = []; const potentialActions: InputActionType[] = [
{
if (showPasswordToggle && type === "password") { id: "clear-input",
actions.push({ icon: X,
onClick: (e) => {
e.stopPropagation();
if (onChange) {
const event = {
target: { value: "" },
currentTarget: { value: "" },
} as React.ChangeEvent<HTMLInputElement>;
onChange(event);
}
if (ref && typeof ref !== "function" && ref.current) {
ref.current.focus();
}
},
ariaLabel: "Clear input",
tooltip: "Clear input",
condition: !!showClearButton && !!value,
},
{
id: "toggle-visibility", id: "toggle-visibility",
icon: isVisible ? EyeOff : Eye, icon: isVisible ? EyeOff : Eye,
onClick: (e) => { onClick: (e) => {
@ -70,10 +92,9 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
}, },
ariaLabel: isVisible ? "Hide password" : "Show password", ariaLabel: isVisible ? "Hide password" : "Show password",
tooltip: isVisible ? "Hide password" : "Show password", tooltip: isVisible ? "Hide password" : "Show password",
}); condition: !!showPasswordToggle && type === "password",
} },
if (showCopyButton) { {
actions.push({
id: "copy-value", id: "copy-value",
icon: isCopied ? Check : Copy, icon: isCopied ? Check : Copy,
onClick: (e) => { onClick: (e) => {
@ -84,10 +105,14 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
}, },
ariaLabel: isCopied ? "Copied!" : "Copy to clipboard", ariaLabel: isCopied ? "Copied!" : "Copy to clipboard",
tooltip: isCopied ? "Copied!" : "Copy to clipboard", tooltip: isCopied ? "Copied!" : "Copy to clipboard",
}); condition: !!showCopyButton,
} },
];
const actions = potentialActions.filter(action => action.condition);
const inputType = showPasswordToggle ? (isVisible ? "text" : "password") : type; const inputType =
showPasswordToggle ? (isVisible ? "text" : "password") : type;
const hasPrefix = !!prefix; const hasPrefix = !!prefix;
const hasSuffix = !!suffix; const hasSuffix = !!suffix;
@ -95,16 +120,15 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
const inputClassName = cn( const inputClassName = cn(
inputVariants({ variant }), inputVariants({ variant }),
hasActions && !hasSuffix && "pr-10",
hasPrefix && "rounded-l-none", hasPrefix && "rounded-l-none",
(hasSuffix || hasActions) && "rounded-r-none border-r-0",
className className
); );
return ( return (
<div className={cn("relative flex w-full items-stretch", containerClassName)}> <div className={cn("relative flex w-full items-stretch", containerClassName)}>
{prefix && ( {prefix && (
<span className="inline-flex items-center rounded-l-md border border-slate-300 bg-slate-100/80 px-3 text-sm text-slate-600 dark:border-slate-700 dark:bg-slate-700 dark:text-slate-300"> <span className="inline-flex items-center rounded-l-md border border-r-0 border-slate-300 bg-slate-100/80 px-3 text-sm text-slate-600 dark:border-slate-700 dark:bg-slate-700 dark:text-slate-300">
{prefix} {prefix}
</span> </span>
)} )}
@ -114,33 +138,32 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
className={inputClassName} className={inputClassName}
ref={ref} ref={ref}
value={value} value={value}
onChange={onChange}
{...props} {...props}
/> />
{(hasSuffix || hasActions) && ( <div className="absolute right-0 top-0 flex h-full items-stretch">
<div className={cn(
"flex items-stretch",
!hasSuffix && hasActions && "border-y border-r border-slate-300 dark:border-slate-700 rounded-r-md"
)}>
{suffix && ( {suffix && (
<span className={cn( <span className={cn(
"inline-flex items-center border border-slate-300 bg-slate-100/80 px-3 text-sm text-slate-600 dark:border-slate-700 dark:bg-slate-700 dark:text-slate-300", "inline-flex items-center border border-l-0 border-slate-300 bg-slate-100/80 px-3 text-sm text-slate-600 dark:border-slate-700 dark:bg-slate-700 dark:text-slate-300",
!hasActions && "rounded-r-md" !hasActions && "rounded-r-md"
)}> )}>
{suffix} {suffix}
</span> </span>
)} )}
{actions.length > 0 && (
{hasActions && (
<div className={cn( <div className={cn(
"flex h-full items-center divide-x divide-slate-300 dark:divide-slate-700", "flex items-center divide-x divide-slate-300 border border-l-0 border-slate-300 dark:divide-slate-700 dark:border-slate-700",
!hasSuffix && "border-l border-slate-300 dark:border-slate-700" !hasSuffix && "rounded-r-md",
"bg-white dark:bg-slate-800"
)}> )}>
{actions?.map((action) => ( {actions.map((action) => (
<button <button
key={action.id} key={action.id}
type="button" type="button"
className={cn( className={cn(
"inline-flex h-full items-center justify-center px-2.5 text-slate-500 hover:bg-slate-100 hover:text-slate-700 focus:outline-none focus:ring-1 focus:ring-slate-400 focus:ring-offset-0 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-200 dark:focus:ring-slate-500", "inline-flex h-full items-center justify-center px-2.5 text-slate-500 hover:bg-slate-100 hover:text-slate-700 focus:outline-none focus:ring-1 focus:ring-slate-400 focus:ring-offset-0 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-200 dark:focus:ring-slate-500 hover:rounded-md dark:hover:rounded-md",
action.id === 'copy-value' && isCopied && "text-green-600 dark:text-green-500" action.id === 'copy-value' && isCopied && "text-green-600 dark:text-green-500"
)} )}
onClick={action.onClick} onClick={action.onClick}
@ -153,7 +176,6 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
</div> </div>
)} )}
</div> </div>
)}
</div> </div>
); );
} }

6
src/components/UI/Sidebar/sidebarButton.tsx

@ -31,14 +31,13 @@ export const SidebarButton = ({
variant={active ? "subtle" : "ghost"} variant={active ? "subtle" : "ghost"}
size="sm" size="sm"
className={cn( className={cn(
"flex w-full items-center", "flex w-full items-center text-wrap",
isCollapsed isCollapsed
? 'justify-center gap-0 px-2 h-9' ? 'justify-center gap-0 px-2 h-9'
: 'justify-start gap-2 min-h-9' : 'justify-start gap-2 min-h-9'
)} )}
disabled={disabled} disabled={disabled}
> >
{/* Icon */}
{Icon && ( {Icon && (
<Icon <Icon
size={isCollapsed ? 20 : 18} size={isCollapsed ? 20 : 18}
@ -50,9 +49,8 @@ export const SidebarButton = ({
<span <span
className={cn( className={cn(
'flex justify-start text-left', 'flex flex-wrap justify-start text-left text-wrap',
'min-w-0', 'min-w-0',
'text-wrap',
'px-1', 'px-1',
'transition-all duration-300 ease-in-out', 'transition-all duration-300 ease-in-out',
isCollapsed isCollapsed

22
src/core/stores/messageStore.ts

@ -71,7 +71,7 @@ export interface MessageStore {
const CURRENT_STORE_VERSION = 0; const CURRENT_STORE_VERSION = 0;
export const useMessageStore = create<MessageStore>()( export const useMessageStore = create<MessageStore>()(
persist( // persist(
(set, get) => ({ (set, get) => ({
messages: { messages: {
direct: {}, // Record<sender, Record<recipient, Record<messageId, Message>>> direct: {}, // Record<sender, Record<recipient, Record<messageId, Message>>>
@ -222,13 +222,13 @@ export const useMessageStore = create<MessageStore>()(
})); }));
} }
}), }),
{ // {
name: 'meshtastic-message-store', // name: 'meshtastic-message-store',
storage: createJSONStorage(() => zustandIndexDBStorage), // storage: createJSONStorage(() => zustandIndexDBStorage),
version: CURRENT_STORE_VERSION, // version: CURRENT_STORE_VERSION,
partialize: (state) => ({ // partialize: (state) => ({
messages: state.messages, // messages: state.messages,
nodeNum: state.nodeNum, // nodeNum: state.nodeNum,
}), // }),
} // }
)); );

4
src/core/subscriptions.ts

@ -123,13 +123,13 @@ export const subscribeAll = (
console.error(`Routing Error: ${routingPacket.data.variant.value}`); console.error(`Routing Error: ${routingPacket.data.variant.value}`);
device.setNodeError(routingPacket.from, Protobuf.Mesh.Routing_Error[routingPacket?.data?.variant?.value]); device.setNodeError(routingPacket.from, Protobuf.Mesh.Routing_Error[routingPacket?.data?.variant?.value]);
// TODO: this has be refactored as it isn't working properly // TODO: this has be refactored as it isn't working properly
// device.setDialogOpen("refreshKeys", true); device.setDialogOpen("refreshKeys", true);
break; break;
case Protobuf.Mesh.Routing_Error.PKI_UNKNOWN_PUBKEY: case Protobuf.Mesh.Routing_Error.PKI_UNKNOWN_PUBKEY:
console.error(`Routing Error: ${routingPacket.data.variant.value}`); console.error(`Routing Error: ${routingPacket.data.variant.value}`);
device.setNodeError(routingPacket.from, Protobuf.Mesh.Routing_Error[routingPacket?.data?.variant?.value]); device.setNodeError(routingPacket.from, Protobuf.Mesh.Routing_Error[routingPacket?.data?.variant?.value]);
// TODO: this has be refactored as it isn't working properly // TODO: this has be refactored as it isn't working properly
// device.setDialogOpen("refreshKeys", true); device.setDialogOpen("refreshKeys", true);
break; break;
default: { default: {
break; break;

1
src/pages/Config/index.tsx

@ -99,6 +99,7 @@ const ConfigPage = () => {
: "Module Config"} : "Module Config"}
actions={[ actions={[
{ {
key: "save",
icon: isError ? SaveOff : SaveIcon, icon: isError ? SaveOff : SaveIcon,
isLoading: isSaving, isLoading: isSaving,
disabled: isSaving, disabled: isSaving,

16
src/pages/Messages.tsx

@ -8,8 +8,8 @@ import { useToast } from "@core/hooks/useToast.ts";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf, Types } from "@meshtastic/core"; import { Protobuf, Types } from "@meshtastic/core";
import { getChannelName } from "@pages/Channels.tsx"; import { getChannelName } from "@pages/Channels.tsx";
import { HashIcon, LockIcon, LockOpenIcon, SearchIcon } from "lucide-react"; import { HashIcon, LockIcon, LockOpenIcon } from "lucide-react";
import { useMemo, useState } from "react"; import { useDeferredValue, useMemo, useState } from "react";
import { MessageInput } from "@components/PageComponents/Messages/MessageInput.tsx"; import { MessageInput } from "@components/PageComponents/Messages/MessageInput.tsx";
import { cn } from "@core/utils/cn.ts"; import { cn } from "@core/utils/cn.ts";
import { MessageType, useMessageStore } from "@core/stores/messageStore.ts"; import { MessageType, useMessageStore } from "@core/stores/messageStore.ts";
@ -24,9 +24,11 @@ export const MessagesPage = () => {
const { toast } = useToast(); const { toast } = useToast();
const { isCollapsed } = useSidebar() const { isCollapsed } = useSidebar()
const [searchTerm, setSearchTerm] = useState<string>(""); const [searchTerm, setSearchTerm] = useState<string>("");
const deferredSearch = useDeferredValue(searchTerm);
const filteredNodes = (): NodeInfoWithUnread[] => { const filteredNodes = (): NodeInfoWithUnread[] => {
const lowerCaseSearchTerm = searchTerm.toLowerCase(); const lowerCaseSearchTerm = deferredSearch.toLowerCase();
return getNodes(node => { return getNodes(node => {
const longName = node.user?.longName?.toLowerCase() ?? ''; const longName = node.user?.longName?.toLowerCase() ?? '';
@ -54,11 +56,6 @@ export const MessagesPage = () => {
const currentChat = { type: chatType, id: activeChat }; const currentChat = { type: chatType, id: activeChat };
console.log(getMessages(MessageType.Broadcast, {
myNodeNum: getNodeNum(),
channel: currentChannel?.index
}));
const renderChatContent = () => { const renderChatContent = () => {
switch (chatType) { switch (chatType) {
case MessageType.Broadcast: case MessageType.Broadcast:
@ -115,6 +112,7 @@ export const MessagesPage = () => {
placeholder="Search nodes..." placeholder="Search nodes..."
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
showClearButton={!!searchTerm}
className={cn('relative w-full p-2 border border-slate-300 rounded-sm bg-white text-slate-900 dark:bg-slate-700 dark:border-slate-600 dark:text-slate-100 focus:outline-none focus:ring-1 focus:ring-slate-700 focus:dark:ring-slate-100')} className={cn('relative w-full p-2 border border-slate-300 rounded-sm bg-white text-slate-900 dark:bg-slate-700 dark:border-slate-600 dark:text-slate-100 focus:outline-none focus:ring-1 focus:ring-slate-700 focus:dark:ring-slate-100')}
/> />
</label> </label>
@ -175,7 +173,7 @@ export const MessagesPage = () => {
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
{renderChatContent()} {renderChatContent()}
<div className="flex-none mt-auto dark:bg-slate-900 p-4"> <div className="flex-none dark:bg-slate-900 p-4">
{(isBroadcast || isDirect) ? ( {(isBroadcast || isDirect) ? (
<MessageInput <MessageInput
to={isDirect ? activeChat : MessageType.Broadcast} to={isDirect ? activeChat : MessageType.Broadcast}

14
src/pages/Nodes.tsx

@ -11,7 +11,7 @@ import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf, type Types } from "@meshtastic/core"; import { Protobuf, type Types } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { LockIcon, LockOpenIcon } from "lucide-react"; import { LockIcon, LockOpenIcon } from "lucide-react";
import { type JSX, useCallback, useEffect, useState } from "react"; import { type JSX, useCallback, useDeferredValue, useEffect, useState } from "react";
import { base16 } from "rfc4648"; import { base16 } from "rfc4648";
import { Input } from "@components/UI/Input.tsx"; import { Input } from "@components/UI/Input.tsx";
import { PageLayout } from "@components/PageLayout.tsx"; import { PageLayout } from "@components/PageLayout.tsx";
@ -33,8 +33,16 @@ const NodesPage = (): JSX.Element => {
Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery> | undefined Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery> | undefined
>(); >();
const [searchTerm, setSearchTerm] = useState<string>(""); const [searchTerm, setSearchTerm] = useState<string>("");
const deferredSearch = useDeferredValue(searchTerm);
const filteredNodes = getNodes() const filteredNodes = getNodes(node => {
if (!node.user) return false;
const lowerCaseSearchTerm = deferredSearch.toLowerCase();
return (
node.user?.longName?.toLowerCase().includes(lowerCaseSearchTerm) ||
node.user?.shortName?.toLowerCase().includes(lowerCaseSearchTerm)
);
})
useEffect(() => { useEffect(() => {
if (!connection) return; if (!connection) return;
@ -75,6 +83,7 @@ const NodesPage = (): JSX.Element => {
placeholder="Search nodes..." placeholder="Search nodes..."
value={searchTerm} value={searchTerm}
className="bg-transparent" className="bg-transparent"
showClearButton={!!searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
/> />
</div> </div>
@ -157,7 +166,6 @@ const NodesPage = (): JSX.Element => {
onOpenChange={() => setSelectedLocation(undefined)} onOpenChange={() => setSelectedLocation(undefined)}
/> />
</div> </div>
<Footer />
</PageLayout> </PageLayout>
</> </>
); );

Loading…
Cancel
Save