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. 165
      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. 126
      src/components/UI/Input.tsx
  12. 6
      src/components/UI/Sidebar/sidebarButton.tsx
  13. 296
      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 { 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/appStore");
const mockUseDevice = vi.mocked(useDevice);
const mockUseAppStore = vi.mocked(useAppStore);
describe("NodeDetailsDialog", () => {
const mockDevice = {
num: 1234,
@ -29,17 +34,21 @@ describe("NodeDetailsDialog", () => {
voltage: 4.2,
uptimeSeconds: 3600,
},
};
} as unknown as Protobuf.Mesh.NodeInfo;
beforeEach(() => {
// Reset mocks before each test
vi.resetAllMocks();
(useDevice as Mock).mockReturnValue({
nodes: new Map([[1234, mockDevice]]),
mockUseDevice.mockReturnValue({
getNode: (nodeNum: number) => {
if (nodeNum === 1234) {
return mockDevice;
}
return undefined;
},
});
(useAppStore as unknown as Mock).mockReturnValue({
mockUseAppStore.mockReturnValue({
nodeNumDetails: 1234,
});
});
@ -47,27 +56,87 @@ describe("NodeDetailsDialog", () => {
it("renders node details correctly", () => {
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 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(/Channel utilization: 75.46%/i)).toBeInTheDocument();
expect(screen.getByText(/Battery level: 88.79%/i)).toBeInTheDocument();
expect(screen.getByText(/Voltage: 4.20V/i)).toBeInTheDocument();
expect(screen.getByText(/Uptime:/i)).toBeInTheDocument();
expect(screen.getByText(/Coordinates:/i)).toBeInTheDocument();
expect(screen.getByText("45, -75")).toBeInTheDocument();
expect(screen.getByText(/Altitude: 200m/i)).toBeInTheDocument();
expect(screen.getByText(/Role:/i)).toBeInTheDocument();
expect(screen.getByText(/All Raw Metrics:/i)).toBeInTheDocument();
});
it("renders null if device is not found", () => {
(useDevice as Mock).mockReturnValue({
nodes: new Map(),
const requestedNodeNum = 5678;
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();
});
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();
});
});

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

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

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

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

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

@ -8,7 +8,7 @@ export interface ChannelChatProps {
}
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" />
<span className="text-sm">No Messages</span>
</div>
@ -24,9 +24,10 @@ export const ChannelChat = ({
const scrollContainer = scrollContainerRef.current;
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 });
}
}, []);
@ -37,25 +38,21 @@ export const ChannelChat = ({
}
}, [messages, scrollToBottom]);
useEffect(() => {
if (messages.length > 0) {
scrollToBottom('instant');
}
}, [scrollToBottom, messages.length]);
if (!messages?.length) {
return (
<ul ref={scrollContainerRef} className="flex flex-1 flex-col items-center justify-center">
<EmptyState />
<div ref={messagesEndRef} />
</ul>
);
}
return (
<ul
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" />
{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 type { Types } from "@meshtastic/core";
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 { debounce } from "@core/utils/debounce.ts";
export interface MessageInputProps {
to: Types.Destination;
@ -24,12 +26,9 @@ export const MessageInput = ({
const [localDraft, setLocalDraft] = useState(getDraft(to));
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 deferredBytes = useDeferredValue(calculateBytes(localDraft));
const chatType = to === MessageType.Broadcast ? MessageType.Broadcast : MessageType.Direct;
@ -52,12 +51,15 @@ export const MessageInput = ({
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
const byteLength = calculateBytes(newValue);
const byteLength = deferredBytes
if (byteLength <= maxBytes) {
setLocalDraft(newValue);
debouncedSetMessageDraft(newValue);
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 { TraceRoute } from "@components/PageComponents/Messages/TraceRoute.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import type { Protobuf } from "@meshtastic/core";
vi.mock("@core/stores/deviceStore");
describe("TraceRoute", () => {
const mockNodes = new Map([
const mockNodes = new Map<number, Protobuf.Mesh.NodeInfo>([
[
1,
{ num: 1, user: { longName: "Node A" } },
{ num: 1, user: { longName: "Node A" } } as Protobuf.Mesh.NodeInfo,
],
[
2,
{ num: 2, user: { longName: "Node B" } },
{ num: 2, user: { longName: "Node B" } } as Protobuf.Mesh.NodeInfo,
],
[
3,
{ num: 3, user: { longName: "Node C" } },
{ num: 3, user: { longName: "Node C" } } as Protobuf.Mesh.NodeInfo,
],
]);
beforeEach(() => {
vi.resetAllMocks();
(useDevice as Mock).mockReturnValue({
nodes: mockNodes,
vi.mocked(useDevice).mockReturnValue({
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("Node A")).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("↓ 20dB")).toBeInTheDocument();
expect(screen.getByText("↓ 30dB")).toBeInTheDocument();
expect(screen.getByText("Source Node")).toBeInTheDocument();
});
it("renders the route back when provided", () => {
@ -64,9 +66,20 @@ describe("TraceRoute", () => {
);
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 A")).toBeInTheDocument();
expect(screen.getByText("↓ 35dB")).toBeInTheDocument();
expect(screen.getByText("↓ 45dB")).toBeInTheDocument();
expect(screen.getByText("↓ 15dB")).toBeInTheDocument();
expect(screen.getByText("↓ 25dB")).toBeInTheDocument();
});
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", () => {
render(
<TraceRoute
from={{ user: { longName: "Source" } } as any}
to={{ user: { longName: "Dest" } } as any}
from={{ user: { longName: "Source" } } as unknown}
to={{ user: { longName: "Dest" } } as unknown}
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();
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>{startNode?.user?.longName}</p>
<p> {snr?.[0] ?? "??"}dB</p>

4
src/components/PageLayout.tsx

@ -105,14 +105,14 @@ export const PageLayout = ({
>
{children}
</main>
{/* <Footer /> */}
<Footer />
</div>
{/* Right Sidebar */}
{rightBar && (
<aside
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
)}
>

7
src/components/Sidebar.tsx

@ -47,8 +47,9 @@ const CollapseToggleButton = () => {
onClick={toggleSidebar}
className={cn(
'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',
'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-100',
'transition-colors duration-300 ease-in-out',
'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'
)}
>
@ -185,7 +186,7 @@ export const Sidebar = ({ children }: SidebarProps) => {
/>
<p
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',
isCollapsed
? 'opacity-0 max-w-0 invisible'

2
src/components/ThemeSwitcher.tsx

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

126
src/components/UI/Input.tsx

@ -1,7 +1,7 @@
import * as React from "react";
import { cn } from "@core/utils/cn.ts";
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 { usePasswordVisibilityToggle } from "@core/hooks/usePasswordVisibilityToggle.ts";
@ -27,6 +27,7 @@ type InputActionType = {
onClick: (e: React.MouseEvent<HTMLButtonElement>) => void;
ariaLabel: string;
tooltip?: string;
condition?: boolean;
};
export interface InputProps
@ -36,6 +37,7 @@ export interface InputProps
suffix?: React.ReactNode;
showPasswordToggle?: boolean;
showCopyButton?: boolean;
showClearButton?: boolean;
containerClassName?: string;
}
@ -50,7 +52,9 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
suffix,
showPasswordToggle,
showCopyButton,
showClearButton,
value,
onChange,
...props
},
ref
@ -58,10 +62,28 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
const { isVisible, toggleVisibility } = usePasswordVisibilityToggle();
const { copy, isCopied } = useCopyToClipboard({ timeout: 1500 });
const actions: InputActionType[] = [];
if (showPasswordToggle && type === "password") {
actions.push({
const potentialActions: InputActionType[] = [
{
id: "clear-input",
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",
icon: isVisible ? EyeOff : Eye,
onClick: (e) => {
@ -70,10 +92,9 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
},
ariaLabel: isVisible ? "Hide password" : "Show password",
tooltip: isVisible ? "Hide password" : "Show password",
});
}
if (showCopyButton) {
actions.push({
condition: !!showPasswordToggle && type === "password",
},
{
id: "copy-value",
icon: isCopied ? Check : Copy,
onClick: (e) => {
@ -84,10 +105,14 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
},
ariaLabel: 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 hasSuffix = !!suffix;
@ -95,16 +120,15 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
const inputClassName = cn(
inputVariants({ variant }),
hasActions && !hasSuffix && "pr-10",
hasPrefix && "rounded-l-none",
(hasSuffix || hasActions) && "rounded-r-none border-r-0",
className
);
return (
<div className={cn("relative flex w-full items-stretch", containerClassName)}>
{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}
</span>
)}
@ -114,46 +138,44 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
className={inputClassName}
ref={ref}
value={value}
onChange={onChange}
{...props}
/>
{(hasSuffix || hasActions) && (
<div className={cn(
"flex items-stretch",
!hasSuffix && hasActions && "border-y border-r border-slate-300 dark:border-slate-700 rounded-r-md"
)}>
{suffix && (
<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",
!hasActions && "rounded-r-md"
)}>
{suffix}
</span>
)}
{actions.length > 0 && (
<div className={cn(
"flex h-full items-center divide-x divide-slate-300 dark:divide-slate-700",
!hasSuffix && "border-l border-slate-300 dark:border-slate-700"
)}>
{actions?.map((action) => (
<button
key={action.id}
type="button"
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",
action.id === 'copy-value' && isCopied && "text-green-600 dark:text-green-500"
)}
onClick={action.onClick}
aria-label={action.ariaLabel}
title={action.tooltip || action.ariaLabel}
>
<action.icon size={18} aria-hidden="true" />
</button>
))}
</div>
)}
</div>
)}
<div className="absolute right-0 top-0 flex h-full items-stretch">
{suffix && (
<span className={cn(
"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"
)}>
{suffix}
</span>
)}
{hasActions && (
<div className={cn(
"flex items-center divide-x divide-slate-300 border border-l-0 border-slate-300 dark:divide-slate-700 dark:border-slate-700",
!hasSuffix && "rounded-r-md",
"bg-white dark:bg-slate-800"
)}>
{actions.map((action) => (
<button
key={action.id}
type="button"
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 hover:rounded-md dark:hover:rounded-md",
action.id === 'copy-value' && isCopied && "text-green-600 dark:text-green-500"
)}
onClick={action.onClick}
aria-label={action.ariaLabel}
title={action.tooltip || action.ariaLabel}
>
<action.icon size={18} aria-hidden="true" />
</button>
))}
</div>
)}
</div>
</div>
);
}

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

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

296
src/core/stores/messageStore.ts

@ -71,164 +71,164 @@ export interface MessageStore {
const CURRENT_STORE_VERSION = 0;
export const useMessageStore = create<MessageStore>()(
persist(
(set, get) => ({
messages: {
direct: {}, // Record<sender, Record<recipient, Record<messageId, Message>>>
broadcast: {},
},
draft: new Map<number, string>(),
activeChat: 0,
chatType: MessageType.Broadcast,
nodeNum: 0,
setNodeNum: (nodeNum) => {
set(produce((state: MessageStore) => {
state.nodeNum = nodeNum;
}));
},
getNodeNum: () => get().nodeNum,
setActiveChat: (chat) => {
set(produce((state: MessageStore) => {
state.activeChat = chat;
}));
},
setChatType: (type) => {
set(produce((state: MessageStore) => {
state.chatType = type;
}));
},
saveMessage: (message) => {
set(produce((state: MessageStore) => {
if (message.type === MessageType.Direct) {
const sender = Number(message.from);
const recipient = Number(message.to);
if (!state.messages.direct[sender]) {
state.messages.direct[sender] = {};
}
if (!state.messages.direct[sender][recipient]) {
state.messages.direct[sender][recipient] = {};
}
state.messages.direct[sender][recipient][message.messageId] = message;
// persist(
(set, get) => ({
messages: {
direct: {}, // Record<sender, Record<recipient, Record<messageId, Message>>>
broadcast: {},
},
draft: new Map<number, string>(),
activeChat: 0,
chatType: MessageType.Broadcast,
nodeNum: 0,
setNodeNum: (nodeNum) => {
set(produce((state: MessageStore) => {
state.nodeNum = nodeNum;
}));
},
getNodeNum: () => get().nodeNum,
setActiveChat: (chat) => {
set(produce((state: MessageStore) => {
state.activeChat = chat;
}));
},
setChatType: (type) => {
set(produce((state: MessageStore) => {
state.chatType = type;
}));
},
saveMessage: (message) => {
set(produce((state: MessageStore) => {
if (message.type === MessageType.Direct) {
const sender = Number(message.from);
const recipient = Number(message.to);
if (!state.messages.direct[sender]) {
state.messages.direct[sender] = {};
}
if (!state.messages.direct[sender][recipient]) {
state.messages.direct[sender][recipient] = {};
}
state.messages.direct[sender][recipient][message.messageId] = message;
} else if (message.type === MessageType.Broadcast) {
const channel = Number(message.channel);
if (!state.messages.broadcast[channel]) {
state.messages.broadcast[channel] = {};
}
state.messages.broadcast[channel][message.messageId] = message;
} else if (message.type === MessageType.Broadcast) {
const channel = Number(message.channel);
if (!state.messages.broadcast[channel]) {
state.messages.broadcast[channel] = {};
}
}));
},
setMessageState: ({
type,
key,
messageId,
newState = MessageState.Ack,
}) => {
set(
produce((state: MessageStore) => {
let message: Message | undefined;
if (type === MessageType.Broadcast) {
const channel = key;
message = state.messages.broadcast?.[channel]?.[messageId];
} else if (type === MessageType.Direct) {
const otherNodeNum = key;
const myNodeNum = state.nodeNum;
message = state.messages.direct?.[myNodeNum]?.[otherNodeNum]?.[messageId];
if (!message) {
message = state.messages.direct?.[otherNodeNum]?.[myNodeNum]?.[messageId];
}
state.messages.broadcast[channel][message.messageId] = message;
}
}));
},
setMessageState: ({
type,
key,
messageId,
newState = MessageState.Ack,
}) => {
set(
produce((state: MessageStore) => {
let message: Message | undefined;
if (type === MessageType.Broadcast) {
const channel = key;
message = state.messages.broadcast?.[channel]?.[messageId];
} else if (type === MessageType.Direct) {
const otherNodeNum = key;
const myNodeNum = state.nodeNum;
message = state.messages.direct?.[myNodeNum]?.[otherNodeNum]?.[messageId];
if (!message) {
message = state.messages.direct?.[otherNodeNum]?.[myNodeNum]?.[messageId];
}
}
if (message) {
message.state = newState;
} else {
console.warn(`Message not found for state update - type: ${type}, key (otherNode/channel): ${key}, messageId: ${messageId}, myNodeNum: ${state.nodeNum}`);
}
}),
);
},
getMessages: (type, options) => {
const state = get();
if (type === MessageType.Broadcast && options.channel !== undefined) {
const messageMap = state.messages.broadcast[options.channel] ?? {};
return Object.values(messageMap).sort((a, b) => a.date - b.date);
}
if (message) {
message.state = newState;
} else {
console.warn(`Message not found for state update - type: ${type}, key (otherNode/channel): ${key}, messageId: ${messageId}, myNodeNum: ${state.nodeNum}`);
}
}),
);
},
getMessages: (type, options) => {
const state = get();
if (type === MessageType.Broadcast && options.channel !== undefined) {
const messageMap = state.messages.broadcast[options.channel] ?? {};
return Object.values(messageMap).sort((a, b) => a.date - b.date);
}
if (type === MessageType.Direct && options.myNodeNum !== undefined && options.otherNodeNum !== undefined) {
const myNodeNum = options.myNodeNum;
const otherNodeNum = options.otherNodeNum;
if (type === MessageType.Direct && options.myNodeNum !== undefined && options.otherNodeNum !== undefined) {
const myNodeNum = options.myNodeNum;
const otherNodeNum = options.otherNodeNum;
// Messages sent BY ME TO OTHER
const sentByMeMap = state.messages.direct?.[myNodeNum]?.[otherNodeNum] ?? {};
const sentByMe = Object.values(sentByMeMap);
// Messages sent BY ME TO OTHER
const sentByMeMap = state.messages.direct?.[myNodeNum]?.[otherNodeNum] ?? {};
const sentByMe = Object.values(sentByMeMap);
// Messages sent BY OTHER TO ME
const sentByOtherMap = state.messages.direct?.[otherNodeNum]?.[myNodeNum] ?? {};
const sentByOther = Object.values(sentByOtherMap);
// Messages sent BY OTHER TO ME
const sentByOtherMap = state.messages.direct?.[otherNodeNum]?.[myNodeNum] ?? {};
const sentByOther = Object.values(sentByOtherMap);
// Merge and sort chronologically
return [...sentByMe, ...sentByOther].sort((a, b) => a.date - b.date);
}
return [];
},
clearMessageByMessageId: ({ type, from, to, channel, messageId }) => {
set(produce((state: MessageStore) => {
if (type === MessageType.Broadcast && channel !== undefined) {
const messageMap = state.messages.broadcast[channel];
if (messageMap?.[messageId]) {
delete messageMap[messageId];
if (Object.keys(messageMap).length === 0) {
delete state.messages.broadcast[channel];
}
// Merge and sort chronologically
return [...sentByMe, ...sentByOther].sort((a, b) => a.date - b.date);
}
return [];
},
clearMessageByMessageId: ({ type, from, to, channel, messageId }) => {
set(produce((state: MessageStore) => {
if (type === MessageType.Broadcast && channel !== undefined) {
const messageMap = state.messages.broadcast[channel];
if (messageMap?.[messageId]) {
delete messageMap[messageId];
if (Object.keys(messageMap).length === 0) {
delete state.messages.broadcast[channel];
}
} else if (type === MessageType.Direct && from !== undefined && to !== undefined) {
const messageMap = state.messages.direct?.[from]?.[to];
if (messageMap?.[messageId]) {
delete messageMap[messageId];
if (Object.keys(messageMap).length === 0) {
delete state.messages.direct[from][to];
if (Object.keys(state.messages.direct[from]).length === 0) {
delete state.messages.direct[from];
}
}
} else if (type === MessageType.Direct && from !== undefined && to !== undefined) {
const messageMap = state.messages.direct?.[from]?.[to];
if (messageMap?.[messageId]) {
delete messageMap[messageId];
if (Object.keys(messageMap).length === 0) {
delete state.messages.direct[from][to];
if (Object.keys(state.messages.direct[from]).length === 0) {
delete state.messages.direct[from];
}
}
console.warn("clearMessageByMessageId called without sufficient identifiers for type", type);
}
}));
},
getDraft: (key) => {
return get().draft.get(key) ?? '';
},
setDraft: (key, message) => {
set(produce((state: MessageStore) => {
state.draft.set(key, message);
}));
},
clearDraft: (key) => {
set(produce((state: MessageStore) => {
state.draft.delete(key);
}));
},
deleteAllMessages: () => {
set(produce((state: MessageStore) => {
state.messages.direct = {};
state.messages.broadcast = {};
}));
}
}),
{
name: 'meshtastic-message-store',
storage: createJSONStorage(() => zustandIndexDBStorage),
version: CURRENT_STORE_VERSION,
partialize: (state) => ({
messages: state.messages,
nodeNum: state.nodeNum,
}),
console.warn("clearMessageByMessageId called without sufficient identifiers for type", type);
}
}));
},
getDraft: (key) => {
return get().draft.get(key) ?? '';
},
setDraft: (key, message) => {
set(produce((state: MessageStore) => {
state.draft.set(key, message);
}));
},
clearDraft: (key) => {
set(produce((state: MessageStore) => {
state.draft.delete(key);
}));
},
deleteAllMessages: () => {
set(produce((state: MessageStore) => {
state.messages.direct = {};
state.messages.broadcast = {};
}));
}
));
}),
// {
// name: 'meshtastic-message-store',
// storage: createJSONStorage(() => zustandIndexDBStorage),
// version: CURRENT_STORE_VERSION,
// partialize: (state) => ({
// messages: state.messages,
// nodeNum: state.nodeNum,
// }),
// }
);

4
src/core/subscriptions.ts

@ -123,13 +123,13 @@ export const subscribeAll = (
console.error(`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
// device.setDialogOpen("refreshKeys", true);
device.setDialogOpen("refreshKeys", true);
break;
case Protobuf.Mesh.Routing_Error.PKI_UNKNOWN_PUBKEY:
console.error(`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
// device.setDialogOpen("refreshKeys", true);
device.setDialogOpen("refreshKeys", true);
break;
default: {
break;

1
src/pages/Config/index.tsx

@ -99,6 +99,7 @@ const ConfigPage = () => {
: "Module Config"}
actions={[
{
key: "save",
icon: isError ? SaveOff : SaveIcon,
isLoading: 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 { Protobuf, Types } from "@meshtastic/core";
import { getChannelName } from "@pages/Channels.tsx";
import { HashIcon, LockIcon, LockOpenIcon, SearchIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { HashIcon, LockIcon, LockOpenIcon } from "lucide-react";
import { useDeferredValue, useMemo, useState } from "react";
import { MessageInput } from "@components/PageComponents/Messages/MessageInput.tsx";
import { cn } from "@core/utils/cn.ts";
import { MessageType, useMessageStore } from "@core/stores/messageStore.ts";
@ -24,9 +24,11 @@ export const MessagesPage = () => {
const { toast } = useToast();
const { isCollapsed } = useSidebar()
const [searchTerm, setSearchTerm] = useState<string>("");
const deferredSearch = useDeferredValue(searchTerm);
const filteredNodes = (): NodeInfoWithUnread[] => {
const lowerCaseSearchTerm = searchTerm.toLowerCase();
const lowerCaseSearchTerm = deferredSearch.toLowerCase();
return getNodes(node => {
const longName = node.user?.longName?.toLowerCase() ?? '';
@ -54,11 +56,6 @@ export const MessagesPage = () => {
const currentChat = { type: chatType, id: activeChat };
console.log(getMessages(MessageType.Broadcast, {
myNodeNum: getNodeNum(),
channel: currentChannel?.index
}));
const renderChatContent = () => {
switch (chatType) {
case MessageType.Broadcast:
@ -115,6 +112,7 @@ export const MessagesPage = () => {
placeholder="Search nodes..."
value={searchTerm}
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')}
/>
</label>
@ -175,7 +173,7 @@ export const MessagesPage = () => {
<div className="flex flex-1 flex-col overflow-hidden">
{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) ? (
<MessageInput
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 { numberToHexUnpadded } from "@noble/curves/abstract/utils";
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 { Input } from "@components/UI/Input.tsx";
import { PageLayout } from "@components/PageLayout.tsx";
@ -33,8 +33,16 @@ const NodesPage = (): JSX.Element => {
Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery> | undefined
>();
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(() => {
if (!connection) return;
@ -75,6 +83,7 @@ const NodesPage = (): JSX.Element => {
placeholder="Search nodes..."
value={searchTerm}
className="bg-transparent"
showClearButton={!!searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
@ -157,7 +166,6 @@ const NodesPage = (): JSX.Element => {
onOpenChange={() => setSelectedLocation(undefined)}
/>
</div>
<Footer />
</PageLayout>
</>
);

Loading…
Cancel
Save