From 99711fc44e3e5e26363b9117e949949722748c68 Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Fri, 2 May 2025 09:00:24 -0400 Subject: [PATCH] Remove duplicate node logic, UI Update, flatten build output (#586) * refactor nodes to getNodes fn. ui updates * fixed several styling issues * fix: message specific styling/overflow * added footer, fixed tests. styling * fix: added theme support back to app component * fix: hide emojis/reactions * fix: added more padding to content element * fix: fixed padding in content element * updated color scheme * fix: more dark mode styling improvements * fix: padding and alignment fixes * fix: prevent left sidebar collapse, added battery component * fix: change scrollbars to "tiny" style, improved message scrolling, fixed bug with message input * message store fixes, ui fixes * fix: disabled message persistance until after release --- deno.lock | 1 + public/Logo.svg | 16 + src/App.tsx | 19 +- src/components/BatteryStatus.tsx | 86 ++++ src/components/CommandPalette/index.tsx | 10 +- src/components/DeviceSelector.tsx | 76 --- src/components/DeviceSelectorButton.tsx | 25 - .../DeleteMessagesDialog.test.tsx | 42 +- .../DeleteMessagesDialog.tsx | 4 +- src/components/Dialog/DeviceNameDialog.tsx | 90 +++- src/components/Dialog/ImportDialog.tsx | 1 - .../Dialog/LocationResponseDialog.tsx | 4 +- .../NodeDetailsDialog.test.tsx | 99 +++- .../NodeDetailsDialog/NodeDetailsDialog.tsx | 6 +- src/components/Dialog/NodeOptionsDialog.tsx | 2 +- src/components/Dialog/QRDialog.tsx | 2 +- .../RefreshKeysDialog.test.tsx | 171 +++--- .../RefreshKeysDialog/RefreshKeysDialog.tsx | 12 +- .../useRefreshKeysDialog.test.ts | 39 +- .../RefreshKeysDialog/useRefreshKeysDialog.ts | 15 +- src/components/Dialog/RemoveNodeDialog.tsx | 4 +- src/components/Dialog/ShutdownDialog.tsx | 1 - .../Dialog/TracerouteResponseDialog.tsx | 6 +- src/components/Form/FormInput.tsx | 3 + src/components/Form/FormWrapper.tsx | 2 +- .../PageComponents/Config/Bluetooth.tsx | 2 +- .../Config/Security/Security.tsx | 5 - src/components/PageComponents/Connect/BLE.tsx | 8 +- .../PageComponents/Connect/HTTP.tsx | 6 +- .../PageComponents/Connect/Serial.tsx | 6 +- .../PageComponents/Map/NodeDetail.tsx | 37 +- .../PageComponents/Messages/ChannelChat.tsx | 91 ++-- .../Messages/MessageActionsMenu.tsx | 2 +- .../Messages/MessageInput.test.tsx | 244 +++++---- .../PageComponents/Messages/MessageInput.tsx | 59 +-- .../PageComponents/Messages/MessageItem.tsx | 188 ++++--- .../Messages/TraceRoute.test.tsx | 47 +- .../PageComponents/Messages/TraceRoute.tsx | 8 +- src/components/PageLayout.tsx | 135 +++-- src/components/Sidebar.tsx | 295 +++++++---- src/components/ThemeSwitcher.tsx | 12 +- src/components/UI/Avatar.tsx | 68 +-- src/components/UI/Button.tsx | 4 +- src/components/UI/Checkbox/index.tsx | 2 +- src/components/UI/Command.tsx | 4 +- src/components/UI/Dialog.tsx | 7 +- src/components/UI/Footer.tsx | 2 +- src/components/UI/Input.tsx | 132 +++-- src/components/UI/Sidebar/SidebarButton.tsx | 81 +++ src/components/UI/Sidebar/SidebarSection.tsx | 47 +- src/components/UI/Sidebar/sidebarButton.tsx | 74 ++- src/components/UI/Tabs.tsx | 2 +- src/components/UI/Toast.tsx | 2 +- src/components/UI/Tooltip.tsx | 2 +- src/components/UI/Typography/Heading.tsx | 2 +- src/components/UI/Typography/Link.tsx | 2 +- src/components/generic/Mono.tsx | 10 +- src/components/generic/Table/index.tsx | 63 ++- src/core/dto/NodeNumToNodeInfoDTO.ts | 40 ++ src/core/dto/PacketToMessageDTO.ts | 2 +- src/core/stores/deviceStore.ts | 370 +++++-------- src/core/stores/messageStore.test.ts | 371 ------------- src/core/stores/messageStore.ts | 234 --------- src/core/stores/messageStore/index.ts | 212 ++++++++ .../stores/messageStore/messageStore.test.ts | 486 ++++++++++++++++++ src/core/stores/messageStore/types.ts | 70 +++ src/core/stores/sidebarStore.tsx | 37 ++ src/core/stores/storage/indexDB.ts | 69 ++- src/core/subscriptions.ts | 21 +- src/core/utils/string.ts | 40 ++ src/index.css | 1 + src/pages/Channels.tsx | 13 +- src/pages/Config/DeviceConfig.tsx | 2 +- src/pages/Config/index.tsx | 21 +- src/pages/Dashboard/index.tsx | 2 +- src/pages/Map/index.tsx | 10 +- src/pages/Messages.tsx | 330 ++++++------ src/pages/Nodes.tsx | 66 +-- 78 files changed, 2759 insertions(+), 2023 deletions(-) create mode 100644 public/Logo.svg create mode 100644 src/components/BatteryStatus.tsx delete mode 100644 src/components/DeviceSelector.tsx delete mode 100644 src/components/DeviceSelectorButton.tsx create mode 100644 src/components/UI/Sidebar/SidebarButton.tsx create mode 100644 src/core/dto/NodeNumToNodeInfoDTO.ts delete mode 100644 src/core/stores/messageStore.test.ts delete mode 100644 src/core/stores/messageStore.ts create mode 100644 src/core/stores/messageStore/index.ts create mode 100644 src/core/stores/messageStore/messageStore.test.ts create mode 100644 src/core/stores/messageStore/types.ts create mode 100644 src/core/stores/sidebarStore.tsx diff --git a/deno.lock b/deno.lock index ddd91281..061fb4a5 100644 --- a/deno.lock +++ b/deno.lock @@ -6468,6 +6468,7 @@ "npm:@radix-ui/react-scroll-area@^1.2.3", "npm:@radix-ui/react-select@^2.1.6", "npm:@radix-ui/react-separator@^1.1.2", + "npm:@radix-ui/react-slider@^1.3.2", "npm:@radix-ui/react-switch@^1.1.3", "npm:@radix-ui/react-tabs@^1.1.3", "npm:@radix-ui/react-toast@^1.2.6", diff --git a/public/Logo.svg b/public/Logo.svg new file mode 100644 index 00000000..e6863f6a --- /dev/null +++ b/public/Logo.svg @@ -0,0 +1,16 @@ + + + +Created with Fabric.js 4.6.0 + + + + + + + + + + + + \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 35152fd1..db735aef 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,5 @@ import { DeviceWrapper } from "@app/DeviceWrapper.tsx"; import { PageRouter } from "@app/PageRouter.tsx"; -import { DeviceSelector } from "@components/DeviceSelector.tsx"; import { DialogManager } from "@components/Dialog/DialogManager.tsx"; import { NewDeviceDialog } from "@components/Dialog/NewDeviceDialog.tsx"; import { KeyBackupReminder } from "@components/KeyBackupReminder.tsx"; @@ -14,6 +13,8 @@ import { ErrorBoundary } from "react-error-boundary"; import { ErrorPage } from "@components/UI/ErrorPage.tsx"; import { MapProvider } from "react-map-gl/maplibre"; import { CommandPalette } from "@components/CommandPalette/index.tsx"; +import { SidebarProvider } from "@core/stores/sidebarStore.tsx"; +import { useTheme } from "@core/hooks/useTheme.ts"; export const App = (): JSX.Element => { @@ -23,6 +24,9 @@ export const App = (): JSX.Element => { const device = getDevice(selectedDevice); + // Sets up light/dark mode based on user preferences or system settings + useTheme() + return ( { /> -
-
- -
+
+ +
{device ? ( -
+
@@ -53,9 +56,9 @@ export const App = (): JSX.Element => { )}
-
+
- + ); }; diff --git a/src/components/BatteryStatus.tsx b/src/components/BatteryStatus.tsx new file mode 100644 index 00000000..bd6022c9 --- /dev/null +++ b/src/components/BatteryStatus.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import { + PlugZapIcon, + BatteryFullIcon, + BatteryMediumIcon, + BatteryLowIcon, +} from 'lucide-react'; +import { Subtle } from "@components/UI/Typography/Subtle.tsx"; + +interface DeviceMetrics { + batteryLevel?: number | null; + voltage?: number | null; +} + +interface BatteryStatusProps { + deviceMetrics?: DeviceMetrics | null; +} + +interface BatteryStateConfig { + condition: (level: number) => boolean; + Icon: React.ElementType; + className: string; + text: (level: number) => string; +} + +const batteryStates: BatteryStateConfig[] = [ + { + condition: level => level > 100, + Icon: PlugZapIcon, + className: 'text-gray-500', + text: () => 'Plugged in', + }, + { + condition: level => level > 80, + Icon: BatteryFullIcon, + className: 'text-green-500', + text: level => `${level}% charging`, + }, + { + condition: level => level > 20, + Icon: BatteryMediumIcon, + className: 'text-yellow-500', + text: level => `${level}% charging`, + }, + { + condition: () => true, + Icon: BatteryLowIcon, + className: 'text-red-500', + text: level => `${level}% charging`, + }, +]; + +const getBatteryState = (level: number) => { + return batteryStates.find(state => state.condition(level)); +}; + + +const BatteryStatus: React.FC = ({ deviceMetrics }) => { + if (deviceMetrics?.batteryLevel === undefined || deviceMetrics?.batteryLevel === null) { + return null; + } + + const { batteryLevel, voltage } = deviceMetrics; + const currentState = getBatteryState(batteryLevel) ?? batteryStates[batteryStates.length - 1]; + + + const BatteryIcon = currentState.Icon; + const iconClassName = currentState.className; + const statusText = currentState.text(batteryLevel); + + const voltageTitle = `${voltage?.toPrecision(3) ?? 'Unknown'} volts`; + + return ( +
+ + + {statusText} + +
+ ); +}; + +export default BatteryStatus; \ No newline at end of file diff --git a/src/components/CommandPalette/index.tsx b/src/components/CommandPalette/index.tsx index 70699d81..11612984 100644 --- a/src/components/CommandPalette/index.tsx +++ b/src/components/CommandPalette/index.tsx @@ -60,7 +60,7 @@ export const CommandPalette = () => { setSelectedDevice, } = useAppStore(); const { getDevices } = useDeviceStore(); - const { setDialogOpen, setActivePage, connection } = useDevice(); + const { setDialogOpen, setActivePage, getNode, connection } = useDevice(); const { pinnedItems, togglePinnedItem } = usePinnedItems({ storageName: 'pinnedCommandMenuGroups' }); const groups: Group[] = [ @@ -115,12 +115,12 @@ export const CommandPalette = () => { icon: ArrowLeftRightIcon, subItems: getDevices().map((device) => ({ label: - device.nodes.get(device.hardware.myNodeNum)?.user?.longName ?? + getNode(device.hardware.myNodeNum)?.user?.longName ?? device.hardware.myNodeNum.toString(), icon: ( @@ -222,7 +222,7 @@ export const CommandPalette = () => { label: "Clear All Stored Message", icon: EraserIcon, action() { - setDialogOpen("clearMessages", true); + setDialogOpen("deleteMessages", true); }, }, ], @@ -262,7 +262,7 @@ export const CommandPalette = () => { type="button" onClick={() => togglePinnedItem(group.label)} className={cn( - "transition-all duration-300 scale-100 cursor-pointer m-0.5 p-2 focus:*:data-label:opacity-100" + "transition-all duration-300 scale-100 cursor-pointer p-2 focus:*:data-label:opacity-100" )} aria-description={ pinnedItems.includes(group.label) diff --git a/src/components/DeviceSelector.tsx b/src/components/DeviceSelector.tsx deleted file mode 100644 index 872b9c04..00000000 --- a/src/components/DeviceSelector.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { DeviceSelectorButton } from "@components/DeviceSelectorButton.tsx"; -import ThemeSwitcher from "@components/ThemeSwitcher.tsx"; -import { Separator } from "@components/UI/Seperator.tsx"; -import { Code } from "@components/UI/Typography/Code.tsx"; -import { useAppStore } from "@core/stores/appStore.ts"; -import { useDeviceStore } from "@core/stores/deviceStore.ts"; -import { HomeIcon, PlusIcon, SearchIcon } from "lucide-react"; -import { Avatar } from "@components/UI/Avatar.tsx"; - -export const DeviceSelector = () => { - const { getDevices } = useDeviceStore(); - const { - selectedDevice, - setSelectedDevice, - setCommandPaletteOpen, - setConnectDialogOpen, - } = useAppStore(); - - return ( - - ); -}; diff --git a/src/components/DeviceSelectorButton.tsx b/src/components/DeviceSelectorButton.tsx deleted file mode 100644 index 62c2bc2a..00000000 --- a/src/components/DeviceSelectorButton.tsx +++ /dev/null @@ -1,25 +0,0 @@ -export interface DeviceSelectorButtonProps { - active: boolean; - onClick: () => void; - children?: React.ReactNode; -} - -export const DeviceSelectorButton = ({ - onClick, - children, -}: DeviceSelectorButtonProps) => ( -
  • - { - /* {active && ( -
    - )} */ - } -
    - {children} -
    -
  • -); diff --git a/src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.test.tsx b/src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.test.tsx index 17e48eb1..67873be0 100644 --- a/src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.test.tsx +++ b/src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.test.tsx @@ -1,9 +1,10 @@ import { render, screen, fireEvent } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { useMessageStore } from "@core/stores/messageStore.ts"; +// Ensure the path is correct for import +import { useMessageStore } from "../../../core/stores/messageStore/index.ts"; import { DeleteMessagesDialog } from "@components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx"; -vi.mock('@core/stores/messageStore.ts', () => ({ +vi.mock('@core/stores/messageStore', () => ({ useMessageStore: vi.fn(() => ({ deleteAllMessages: vi.fn(), })), @@ -14,17 +15,36 @@ describe('DeleteMessagesDialog', () => { const mockClearAllMessages = vi.fn(); beforeEach(() => { - vi.mocked(useMessageStore).mockReturnValue({ deleteAllMessages: mockClearAllMessages }); mockOnOpenChange.mockClear(); mockClearAllMessages.mockClear(); + + const mockedUseMessageStore = vi.mocked(useMessageStore); + mockedUseMessageStore.mockImplementation(() => ({ + deleteAllMessages: mockClearAllMessages + })); + mockedUseMessageStore.mockClear(); + + }); + + it('calls onOpenChange with false when the close button (X) is clicked', () => { + render(); + const closeButton = screen.queryByTestId('dialog-close-button'); + if (!closeButton) { + throw new Error("Dialog close button with data-testid='dialog-close-button' not found. Did you add it to the component?"); + } + fireEvent.click(closeButton); + expect(mockOnOpenChange).toHaveBeenCalledTimes(1); + expect(mockOnOpenChange).toHaveBeenCalledWith(false); }); + + it('renders the dialog when open is true', () => { render(); - expect(screen.getByText('Clear All Messages')).toBeVisible(); - expect(screen.getByText(/This action will clear all message history./)).toBeVisible(); - expect(screen.getByRole('button', { name: 'Dismiss' })).toBeVisible(); - expect(screen.getByRole('button', { name: 'Clear Messages' })).toBeVisible(); + expect(screen.getByText('Clear All Messages')).toBeInTheDocument(); + expect(screen.getByText(/This action will clear all message history./)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Dismiss' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Clear Messages' })).toBeInTheDocument(); }); it('does not render the dialog when open is false', () => { @@ -32,15 +52,10 @@ describe('DeleteMessagesDialog', () => { expect(screen.queryByText('Clear All Messages')).toBeNull(); }); - it('calls onOpenChange with false when the close button is clicked', () => { - render(); - fireEvent.click(screen.getByRole('button', { name: 'Close' })); - expect(mockOnOpenChange).toHaveBeenCalledWith(false); - }); - it('calls onOpenChange with false when the dismiss button is clicked', () => { render(); fireEvent.click(screen.getByRole('button', { name: 'Dismiss' })); + expect(mockOnOpenChange).toHaveBeenCalledTimes(1); // Add count check expect(mockOnOpenChange).toHaveBeenCalledWith(false); }); @@ -48,6 +63,7 @@ describe('DeleteMessagesDialog', () => { render(); fireEvent.click(screen.getByRole('button', { name: 'Clear Messages' })); expect(mockClearAllMessages).toHaveBeenCalledTimes(1); + expect(mockOnOpenChange).toHaveBeenCalledTimes(1); // Add count check expect(mockOnOpenChange).toHaveBeenCalledWith(false); }); }); \ No newline at end of file diff --git a/src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx b/src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx index c93f2795..47fe0b92 100644 --- a/src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx +++ b/src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx @@ -10,7 +10,7 @@ import { DialogTitle, } from "@components/UI/Dialog.tsx"; import { AlertTriangleIcon } from "lucide-react"; -import { useMessageStore } from "@core/stores/messageStore.ts"; +import { useMessageStore } from "../../../core/stores/messageStore/index.ts"; export interface DeleteMessagesDialogProps { open: boolean; @@ -29,7 +29,7 @@ export const DeleteMessagesDialog = ({ return ( - + diff --git a/src/components/Dialog/DeviceNameDialog.tsx b/src/components/Dialog/DeviceNameDialog.tsx index 765e4097..c41c81ba 100644 --- a/src/components/Dialog/DeviceNameDialog.tsx +++ b/src/components/Dialog/DeviceNameDialog.tsx @@ -10,10 +10,11 @@ import { DialogHeader, DialogTitle, } from "@components/UI/Dialog.tsx"; -import { Input } from "@components/UI/Input.tsx"; import { Label } from "@components/UI/Label.tsx"; import { Protobuf } from "@meshtastic/core"; import { useForm } from "react-hook-form"; +import { GenericInput } from "@components/Form/FormInput.tsx"; +import { validateMaxByteLength } from "@core/utils/string.ts"; export interface User { longName: string; @@ -24,32 +25,44 @@ export interface DeviceNameDialogProps { open: boolean; onOpenChange: (open: boolean) => void; } +const MAX_LONG_NAME_BYTE_LENGTH = 40; +const MAX_SHORT_NAME_BYTE_LENGTH = 4; export const DeviceNameDialog = ({ open, onOpenChange, }: DeviceNameDialogProps) => { - const { hardware, nodes, connection } = useDevice(); + const { hardware, getNode, connection } = useDevice(); + const myNode = getNode(hardware.myNodeNum); - const myNode = nodes.get(hardware.myNodeNum); + const defaultValues = { + longName: myNode?.user?.longName ?? "Unknown", + shortName: myNode?.user?.shortName ?? "??", + }; - const { register, handleSubmit } = useForm({ - values: { - longName: myNode?.user?.longName ?? "Unknown", - shortName: myNode?.user?.shortName ?? "Unknown", - }, + const { getValues, setValue, reset, control, handleSubmit } = useForm({ + values: defaultValues, }); + const { currentLength: currentLongNameLength } = validateMaxByteLength(getValues('longName'), MAX_LONG_NAME_BYTE_LENGTH); + const { currentLength: currentShortNameLength } = validateMaxByteLength(getValues('shortName'), MAX_SHORT_NAME_BYTE_LENGTH); + const onSubmit = handleSubmit((data) => { connection?.setOwner( create(Protobuf.Mesh.UserSchema, { - ...myNode?.user, + ...(myNode?.user ?? {}), ...data, }), ); onOpenChange(false); }); + const handleReset = () => { + reset({ longName: "", shortName: "" }); + setValue("longName", ""); + setValue("shortName", ""); + }; + return ( @@ -60,22 +73,51 @@ export const DeviceNameDialog = ({ The Device will restart once the config is saved. -
    -
    - - - - +
    + + - -
    - - - +
    +
    + + +
    + + + + + +
    ); -}; +}; \ No newline at end of file diff --git a/src/components/Dialog/ImportDialog.tsx b/src/components/Dialog/ImportDialog.tsx index 805d2f3e..242502cd 100644 --- a/src/components/Dialog/ImportDialog.tsx +++ b/src/components/Dialog/ImportDialog.tsx @@ -109,7 +109,6 @@ export const ImportDialog = ({ { setImportDialogInput(e.target.value); }} diff --git a/src/components/Dialog/LocationResponseDialog.tsx b/src/components/Dialog/LocationResponseDialog.tsx index c4df3761..4625fa87 100644 --- a/src/components/Dialog/LocationResponseDialog.tsx +++ b/src/components/Dialog/LocationResponseDialog.tsx @@ -21,9 +21,9 @@ export const LocationResponseDialog = ({ open, onOpenChange, }: LocationResponseDialogProps) => { - const { nodes } = useDevice(); + const { getNode } = useDevice(); - const from = nodes.get(location?.from ?? 0); + const from = getNode(location?.from ?? 0); const longName = from?.user?.longName ?? (from ? `!${numberToHexUnpadded(from?.num)}` : "Unknown"); const shortName = from?.user?.shortName ?? diff --git a/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.test.tsx b/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.test.tsx index d8d1bea9..2b2edc23 100644 --- a/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.test.tsx +++ b/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( { }} />); - 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( { }} />); + mockUseDevice.mockReturnValue({ + getNode: (nodeNum: number) => { + if (nodeNum === requestedNodeNum) { + return undefined; + } + if (nodeNum === 1234) { + return mockDevice; + } + return undefined; + }, + }); + + const { container } = render( { }} />); + + 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( { }} />); + + 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( { }} />); + + 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( { }} />); + + expect(screen.getByText(/Last Heard: Never/i)).toBeInTheDocument(); + }); + +}); \ No newline at end of file diff --git a/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx b/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx index f010fc67..9001c5f1 100644 --- a/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx +++ b/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx @@ -29,10 +29,10 @@ export const NodeDetailsDialog = ({ open, onOpenChange, }: NodeDetailsDialogProps) => { - const { nodes } = useDevice(); + const { getNode } = useDevice(); const { nodeNumDetails } = useAppStore(); - const device = nodes.get(nodeNumDetails); + const device = getNode(nodeNumDetails); if (!device) return null; @@ -131,7 +131,7 @@ export const NodeDetailsDialog = ({ {device.deviceMetrics && (
    -

    +

    Device Metrics:

    {deviceMetricsMap.map( diff --git a/src/components/Dialog/NodeOptionsDialog.tsx b/src/components/Dialog/NodeOptionsDialog.tsx index 6a239902..dcf20f00 100644 --- a/src/components/Dialog/NodeOptionsDialog.tsx +++ b/src/components/Dialog/NodeOptionsDialog.tsx @@ -13,7 +13,7 @@ import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; import { TrashIcon } from "lucide-react"; import { Button } from "../UI/Button.tsx"; -import { MessageType, useMessageStore } from "@core/stores/messageStore.ts"; +import { MessageType, useMessageStore } from "../../core/stores/messageStore/index.ts"; export interface NodeOptionsDialogProps { node: Protobuf.Mesh.NodeInfo | undefined; diff --git a/src/components/Dialog/QRDialog.tsx b/src/components/Dialog/QRDialog.tsx index c4eecfe4..6eb86356 100644 --- a/src/components/Dialog/QRDialog.tsx +++ b/src/components/Dialog/QRDialog.tsx @@ -133,8 +133,8 @@ export const QRDialog = ({ ({ - useMessageStore: vi.fn(), -})); - -const mockNodeWithError: Partial = { - 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, - }); +vi.mock("@core/stores/messageStore"); +vi.mock("./useRefreshKeysDialog"); + +const mockUseMessageStore = vi.mocked(useMessageStore); +const mockUseRefreshKeysDialog = vi.mocked(useRefreshKeysDialog); + +const getInitialState = () => useDeviceStore.getInitialState?.() ?? { devices: new Map(), remoteDevices: new Map() }; + +beforeEach(() => { + useDeviceStore.setState(getInitialState(), true); + vi.clearAllMocks(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +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 render the dialog with dynamic content when open and data is available", () => { - render(); - - 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(); - }); + render( + + + + ); - it("should call handleNodeRemove when 'Request New Keys' button is clicked", () => { - render(); - fireEvent.click(screen.getByRole('button', { name: /request new keys/i })); - expect(mockHandleNodeRemove).toHaveBeenCalledTimes(1); - }); + 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 call handleCloseDialog when 'Dismiss' button is clicked", () => { - render(); - fireEvent.click(screen.getByRole('button', { name: /dismiss/i })); - expect(mockHandleCloseDialog).toHaveBeenCalledTimes(1); - }); +test("does not render dialog if no error exists for active chat", () => { + const deviceId = 1; + const activeChatNum = 54321; - it("should call handleCloseDialog when the explicit DialogClose button is clicked", () => { - render(); - fireEvent.click(screen.getByRole('button', { name: /close/i })); // Use the aria-label - expect(mockHandleCloseDialog).toHaveBeenCalledTimes(1); - }); + useDeviceStore.getState().addDevice(deviceId); + const currentDeviceState = useDeviceStore.getState().getDevice(deviceId); + if (!currentDeviceState) throw new Error("Device not found"); - it("should not render the dialog when open is false", () => { - render(); - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + mockUseMessageStore.mockReturnValue({ activeChat: activeChatNum }); + mockUseRefreshKeysDialog.mockReturnValue({ + handleCloseDialog: vi.fn(), + handleNodeRemove: vi.fn(), }); - it("should render null if nodeErrorNum is not found for activeChat", () => { - vi.mocked(useDevice).mockReturnValue({ - nodeErrors: new Map(), - nodes: mockNodes, - }); - const { container } = render(); - expect(container.firstChild).toBeNull(); - }); + const { container } = render( + + + + ); - it("should render null if nodeWithError is not found for nodeErrorNum.node", () => { - vi.mocked(useDevice).mockReturnValue({ - nodeErrors: mockNodeErrors, - nodes: new Map(), - }); - const { container } = render(); - expect(container.firstChild).toBeNull(); - }); -}); \ No newline at end of file + expect(container.firstChild).toBeNull(); +}); diff --git a/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx b/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx index ad1373f4..98625ceb 100644 --- a/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx +++ b/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx @@ -9,7 +9,7 @@ import { Button } from "@components/UI/Button.tsx"; import { LockKeyholeOpenIcon } from "lucide-react"; import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts"; import { useDevice } from "@core/stores/deviceStore.ts"; -import { useMessageStore } from "@core/stores/messageStore.ts"; +import { useMessageStore } from "../../../core/stores/messageStore/index.ts"; export interface RefreshKeysDialogProps { open: boolean; @@ -18,22 +18,16 @@ export interface RefreshKeysDialogProps { export const RefreshKeysDialog = ({ open, onOpenChange }: RefreshKeysDialogProps) => { const { activeChat } = useMessageStore(); - const { nodeErrors, nodes } = useDevice(); + const { nodeErrors, getNode } = useDevice(); const { handleCloseDialog, handleNodeRemove } = useRefreshKeysDialog(); const nodeErrorNum = nodeErrors.get(activeChat); if (!nodeErrorNum) { - console.error("Node with error not found"); return null; } - const nodeWithError = nodes.get(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 ?? ""}`, diff --git a/src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.test.ts b/src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.test.ts index 9e8fa30e..b50eaee6 100644 --- a/src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.test.ts +++ b/src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.test.ts @@ -2,12 +2,12 @@ import { renderHook, act } from "@testing-library/react"; import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts"; import { beforeEach, describe, expect, it, Mock, vi } from "vitest"; import { useDevice } from "@core/stores/deviceStore.ts"; +import { useMessageStore } from "@core/stores/messageStore/index.ts"; -vi.mock("@core/stores/messageStore.ts", () => ({ +vi.mock("@core/stores/messageStore", () => ({ useMessageStore: vi.fn(() => ({ activeChat: "chat-123" })), })); - -vi.mock("@core/stores/deviceStore.ts", () => ({ +vi.mock("@core/stores/deviceStore", () => ({ useDevice: vi.fn(() => ({ removeNode: vi.fn(), setDialogOpen: vi.fn(), @@ -23,46 +23,50 @@ describe("useRefreshKeysDialog Hook", () => { let clearNodeErrorMock: Mock; beforeEach(() => { + vi.clearAllMocks(); + removeNodeMock = vi.fn(); setDialogOpenMock = vi.fn(); - getNodeErrorMock = vi.fn(); + getNodeErrorMock = vi.fn().mockReturnValue(undefined); clearNodeErrorMock = vi.fn(); - (useDevice as Mock).mockReturnValue({ + vi.mocked(useDevice).mockReturnValue({ removeNode: removeNodeMock, setDialogOpen: setDialogOpenMock, getNodeError: getNodeErrorMock, clearNodeError: clearNodeErrorMock, }); + + vi.mocked(useMessageStore).mockReturnValue({ + activeChat: "chat-123" + }); }); it("handleNodeRemove should remove the node and update dialog if there is an error", () => { getNodeErrorMock.mockReturnValue({ node: "node-abc" }); const { result } = renderHook(() => useRefreshKeysDialog()); + act(() => { result.current.handleNodeRemove(); }); - act(() => { - result.current.handleNodeRemove(); - }); - + expect(getNodeErrorMock).toHaveBeenCalledTimes(1); expect(getNodeErrorMock).toHaveBeenCalledWith("chat-123"); + expect(clearNodeErrorMock).toHaveBeenCalledTimes(1); expect(clearNodeErrorMock).toHaveBeenCalledWith("chat-123"); + expect(removeNodeMock).toHaveBeenCalledTimes(1); expect(removeNodeMock).toHaveBeenCalledWith("node-abc"); + expect(setDialogOpenMock).toHaveBeenCalledTimes(1); expect(setDialogOpenMock).toHaveBeenCalledWith("refreshKeys", false); }); it("handleNodeRemove should do nothing if there is no error", () => { - getNodeErrorMock.mockReturnValue(undefined); - const { result } = renderHook(() => useRefreshKeysDialog()); + act(() => { result.current.handleNodeRemove(); }); - act(() => { - result.current.handleNodeRemove(); - }); - + expect(getNodeErrorMock).toHaveBeenCalledTimes(1); + expect(getNodeErrorMock).toHaveBeenCalledWith("chat-123"); + expect(clearNodeErrorMock).not.toHaveBeenCalled(); expect(removeNodeMock).not.toHaveBeenCalled(); expect(setDialogOpenMock).not.toHaveBeenCalled(); - expect(clearNodeErrorMock).not.toHaveBeenCalled(); }); it("handleCloseDialog should close the dialog", () => { @@ -72,6 +76,7 @@ describe("useRefreshKeysDialog Hook", () => { result.current.handleCloseDialog(); }); + expect(setDialogOpenMock).toHaveBeenCalledTimes(1); expect(setDialogOpenMock).toHaveBeenCalledWith("refreshKeys", false); }); -}); +}); \ No newline at end of file diff --git a/src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.ts b/src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.ts index 4a291eea..ba4f6740 100644 --- a/src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.ts +++ b/src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.ts @@ -1,28 +1,27 @@ import { useCallback } from "react"; import { useDevice } from "@core/stores/deviceStore.ts"; -import { useMessageStore } from "@core/stores/messageStore.ts"; +import { useMessageStore } from "@core/stores/messageStore/index.ts"; export function useRefreshKeysDialog() { const { removeNode, setDialogOpen, clearNodeError, getNodeError } = useDevice(); const { activeChat } = useMessageStore(); + const handleCloseDialog = useCallback(() => { + setDialogOpen('refreshKeys', false); + }, [setDialogOpen]); + const handleNodeRemove = useCallback(() => { const nodeWithError = getNodeError(activeChat); if (!nodeWithError) { return; } clearNodeError(activeChat); - handleCloseDialog();; + handleCloseDialog(); return removeNode(nodeWithError?.node); - }, [activeChat, clearNodeError, setDialogOpen, removeNode]); - - const handleCloseDialog = useCallback(() => { - setDialogOpen('refreshKeys', false); - }, [setDialogOpen]) + }, [activeChat, clearNodeError, getNodeError, removeNode, handleCloseDialog]); return { handleCloseDialog, handleNodeRemove }; - } \ No newline at end of file diff --git a/src/components/Dialog/RemoveNodeDialog.tsx b/src/components/Dialog/RemoveNodeDialog.tsx index 66297f23..66c42dc6 100644 --- a/src/components/Dialog/RemoveNodeDialog.tsx +++ b/src/components/Dialog/RemoveNodeDialog.tsx @@ -21,7 +21,7 @@ export const RemoveNodeDialog = ({ open, onOpenChange, }: RemoveNodeDialogProps) => { - const { connection, nodes, removeNode } = useDevice(); + const { connection, getNode, removeNode } = useDevice(); const { nodeNumToBeRemoved } = useAppStore(); const onSubmit = () => { @@ -42,7 +42,7 @@ export const RemoveNodeDialog = ({
    - +
    diff --git a/src/components/Dialog/ShutdownDialog.tsx b/src/components/Dialog/ShutdownDialog.tsx index 28fdb381..ddc50edb 100644 --- a/src/components/Dialog/ShutdownDialog.tsx +++ b/src/components/Dialog/ShutdownDialog.tsx @@ -41,7 +41,6 @@ export const ShutdownDialog = ({ type="number" value={time} onChange={(e) => setTime(Number.parseInt(e.target.value))} - className="dark:text-slate-900" suffix="Minutes" />
    @@ -120,7 +120,7 @@ export const HTTP = ({ closeDialog, setConnectionInProgress, connectionInProgres
    diff --git a/src/components/PageComponents/Connect/Serial.tsx b/src/components/PageComponents/Connect/Serial.tsx index b9263e6e..b9cd7dd5 100644 --- a/src/components/PageComponents/Connect/Serial.tsx +++ b/src/components/PageComponents/Connect/Serial.tsx @@ -8,7 +8,7 @@ import { randId } from "@core/utils/randId.ts"; import { MeshDevice } from "@meshtastic/core"; import { TransportWebSerial } from "@meshtastic/transport-web-serial"; import { useCallback, useEffect, useState } from "react"; -import { useMessageStore } from "@core/stores/messageStore.ts"; +import { useMessageStore } from "../../../core/stores/messageStore/index.ts"; export const Serial = ({ setConnectionInProgress, closeDialog }: TabElementProps) => { const [serialPorts, setSerialPorts] = useState([]); @@ -52,7 +52,7 @@ export const Serial = ({ setConnectionInProgress, closeDialog }: TabElementProps
    )), @@ -23,16 +20,21 @@ vi.mock('@components/UI/Input.tsx', () => ({ placeholder={placeholder} value={value} onChange={onChange} + data-testid="message-input-field" /> )), })); -vi.mock('@core/stores/deviceStore.ts', () => ({ - useDevice: vi.fn(), -})); +const mockSetDraft = vi.fn(); +const mockGetDraft = vi.fn(); +const mockClearDraft = vi.fn(); -vi.mock('@core/stores/messageStore.ts', () => ({ - useMessageStore: vi.fn(), +vi.mock('@core/stores/messageStore', () => ({ + useMessageStore: vi.fn(() => ({ + setDraft: mockSetDraft, + getDraft: mockGetDraft, + clearDraft: mockClearDraft, + })), MessageState: { Ack: 'ack', Waiting: 'waiting', @@ -44,111 +46,177 @@ vi.mock('@core/stores/messageStore.ts', () => ({ }, })); -vi.mock('@core/utils/debounce.ts', () => ({ - debounce: vi.fn((fn) => fn), -})); - vi.mock('lucide-react', () => ({ SendIcon: vi.fn(() => ), })); describe('MessageInput', () => { - const mockSetMessageState = vi.fn(); - const mockSetActiveChat = vi.fn(); - const mockSetDraft = vi.fn(); - const mockGetDraft = vi.fn(); - const mockClearDraft = vi.fn(); - const mockSendText = vi.fn(); + const mockOnSend = vi.fn(); + const defaultProps: MessageInputProps = { + onSend: mockOnSend, + to: 123, + maxBytes: 256, + }; beforeEach(() => { - (useDevice as ReturnType).mockReturnValue({ - connection: { - sendText: mockSendText, - }, - }); - - (useMessageStore as unknown as ReturnType).mockReturnValue({ - setMessageState: mockSetMessageState, - activeChat: 123, - setDraft: mockSetDraft, - getDraft: mockGetDraft, - clearDraft: mockClearDraft, - }); + vi.clearAllMocks(); - mockSetMessageState.mockClear(); - mockSetActiveChat.mockClear(); - mockSetDraft.mockClear(); - mockGetDraft.mockClear(); - mockClearDraft.mockClear(); - mockSendText.mockClear(); - (debounce as ReturnType).mockImplementation((fn) => fn); + mockGetDraft.mockReturnValue(''); }); - const renderComponent = (props: { to: Types.Destination; channel: Types.ChannelNumber; maxBytes: number }) => { - render(); + const renderComponent = (props: Partial = {}) => { + render(); }; - it.skip('sends text message and updates state to Ack on submit', async () => { - renderComponent({ to: 2, channel: 3, maxBytes: 256 }); + it('should render the input field, byte counter, and send button', () => { + renderComponent(); + expect(screen.getByPlaceholderText('Enter Message')).toBeInTheDocument(); + expect(screen.getByTestId('byte-counter')).toBeInTheDocument(); + expect(screen.getByRole('button')).toBeInTheDocument(); + expect(screen.getByTestId('send-icon')).toBeInTheDocument(); + }); + + it('should initialize with the draft from the store', () => { + const initialDraft = 'Existing draft message'; + mockGetDraft.mockImplementation((key) => { + return key === defaultProps.to ? initialDraft : ''; + }); + + renderComponent(); + const inputElement = screen.getByPlaceholderText('Enter Message') as HTMLInputElement; - fireEvent.change(inputElement, { target: { value: 'Hello' } }); + expect(inputElement.value).toBe(initialDraft); + expect(mockGetDraft).toHaveBeenCalledWith(defaultProps.to); + const expectedBytes = new Blob([initialDraft]).size; + expect(screen.getByTestId('byte-counter')).toHaveTextContent(`${expectedBytes}/${defaultProps.maxBytes}`); + }); + + it('should update input value, byte counter, and call setDraft on change within limits', () => { + renderComponent(); + const inputElement = screen.getByPlaceholderText('Enter Message'); + const testMessage = 'Hello there!'; + const expectedBytes = new Blob([testMessage]).size; + + fireEvent.change(inputElement, { target: { value: testMessage } }); + + expect((inputElement as HTMLInputElement).value).toBe(testMessage); + expect(screen.getByTestId('byte-counter')).toHaveTextContent(`${expectedBytes}/${defaultProps.maxBytes}`); + expect(mockSetDraft).toHaveBeenCalledTimes(1); + expect(mockSetDraft).toHaveBeenCalledWith(defaultProps.to, testMessage); + }); + + it('should NOT update input value or call setDraft if maxBytes is exceeded', () => { + const smallMaxBytes = 5; + renderComponent({ maxBytes: smallMaxBytes }); + const inputElement = screen.getByPlaceholderText('Enter Message'); + const initialValue = '12345'; + const excessiveValue = '123456'; + + fireEvent.change(inputElement, { target: { value: initialValue } }); + expect((inputElement as HTMLInputElement).value).toBe(initialValue); + expect(mockSetDraft).toHaveBeenCalledWith(defaultProps.to, initialValue); + mockSetDraft.mockClear(); + + fireEvent.change(inputElement, { target: { value: excessiveValue } }); + + expect((inputElement as HTMLInputElement).value).toBe(initialValue); + expect(screen.getByTestId('byte-counter')).toHaveTextContent(`${smallMaxBytes}/${smallMaxBytes}`); + expect(mockSetDraft).not.toHaveBeenCalled(); + }); + + it('should call onSend, clear input, reset byte counter, and call clearDraft on valid submit', async () => { + renderComponent(); + const inputElement = screen.getByPlaceholderText('Enter Message'); const formElement = screen.getByRole('form'); + const testMessage = 'Send this message'; + + fireEvent.change(inputElement, { target: { value: testMessage } }); fireEvent.submit(formElement); await waitFor(() => { - expect(mockSendText).toHaveBeenCalledWith('Hello', 2, true, 3); - expect(mockSetMessageState).toHaveBeenCalledWith({ - type: 'direct', - key: 123, - messageId: undefined, - newState: 'ack', - }); - expect(mockClearDraft).toHaveBeenCalledWith(2); - expect(inputElement.value).toBe(''); - expect(screen.getByTestId('byte-counter')).toHaveTextContent('0/256'); + expect(mockOnSend).toHaveBeenCalledTimes(1); + expect(mockOnSend).toHaveBeenCalledWith(testMessage); + expect((inputElement as HTMLInputElement).value).toBe(''); + expect(screen.getByTestId('byte-counter')).toHaveTextContent(`0/${defaultProps.maxBytes}`); + expect(mockClearDraft).toHaveBeenCalledTimes(1); + expect(mockClearDraft).toHaveBeenCalledWith(defaultProps.to); }); }); - it.skip('sends broadcast message if to is "broadcast" and updates state to Ack', async () => { - renderComponent({ to: 'broadcast', channel: 5, maxBytes: 256 }); - const inputElement = screen.getByPlaceholderText('Enter Message') as HTMLInputElement; - fireEvent.change(inputElement, { target: { value: 'Broadcast message' } }); + it('should trim whitespace before calling onSend', async () => { + renderComponent(); + const inputElement = screen.getByPlaceholderText('Enter Message'); const formElement = screen.getByRole('form'); + const testMessageWithWhitespace = ' Trim me! '; + const expectedTrimmedMessage = 'Trim me!'; + + fireEvent.change(inputElement, { target: { value: testMessageWithWhitespace } }); fireEvent.submit(formElement); await waitFor(() => { - expect(mockSendText).toHaveBeenCalledWith('Broadcast message', 'broadcast', true, 5); - expect(mockSetMessageState).toHaveBeenCalledWith({ - type: 'broadcast', - key: 123, - messageId: undefined, - newState: 'ack', - }); - expect(mockClearDraft).toHaveBeenCalledWith('broadcast'); - expect(inputElement.value).toBe(''); - expect(screen.getByTestId('byte-counter')).toHaveTextContent('0/256'); + expect(mockOnSend).toHaveBeenCalledTimes(1); + expect(mockOnSend).toHaveBeenCalledWith(expectedTrimmedMessage); + expect(mockClearDraft).toHaveBeenCalledWith(defaultProps.to); }); }); - it('updates state to Failed if sendText throws an error', async () => { - mockSendText.mockRejectedValue({ id: 456 }); - renderComponent({ to: 3, channel: 1, maxBytes: 256 }); - const inputElement = screen.getByPlaceholderText('Enter Message') as HTMLInputElement; - fireEvent.change(inputElement, { target: { value: 'Error message' } }); + it('should not call onSend or clearDraft if input is empty on submit', async () => { + renderComponent(); + const inputElement = screen.getByPlaceholderText('Enter Message'); const formElement = screen.getByRole('form'); + + expect((inputElement as HTMLInputElement).value).toBe(''); + fireEvent.submit(formElement); - await waitFor(() => { - expect(mockSendText).toHaveBeenCalledWith('Error message', 3, true, 1); - expect(mockSetMessageState).toHaveBeenCalledWith({ - type: 'direct', - key: 123, - messageId: 456, - newState: 'failed', - }); - expect(mockClearDraft).toHaveBeenCalledWith(3); - expect(inputElement.value).toBe(''); - expect(screen.getByTestId('byte-counter')).toHaveTextContent('0/256'); + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 50)); + }); + + expect(mockOnSend).not.toHaveBeenCalled(); + expect(mockClearDraft).not.toHaveBeenCalled(); + }); + + it('should not call onSend or clearDraft if input contains only whitespace on submit', async () => { + renderComponent(); + const inputElement = screen.getByTestId('message-input-field'); + const formElement = screen.getByRole('form'); + const whitespaceMessage = ' \t '; + + fireEvent.change(inputElement, { target: { value: whitespaceMessage } }); + expect((inputElement as HTMLInputElement).value).toBe(whitespaceMessage); + + fireEvent.submit(formElement); + + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 50)); }); + + expect(mockOnSend).not.toHaveBeenCalled(); + expect(mockClearDraft).not.toHaveBeenCalled(); + + expect((inputElement as HTMLInputElement).value).toBe(whitespaceMessage); + }); + + it('should work with broadcast destination for drafts', () => { + const broadcastDest: Types.Destination = 'broadcast'; + mockGetDraft.mockImplementation((key) => key === broadcastDest ? 'Broadcast draft' : ''); + + renderComponent({ to: broadcastDest }); + + expect(mockGetDraft).toHaveBeenCalledWith(broadcastDest); + expect((screen.getByPlaceholderText('Enter Message') as HTMLInputElement).value).toBe('Broadcast draft'); + + const inputElement = screen.getByPlaceholderText('Enter Message'); + const formElement = screen.getByRole('form'); + const newMessage = 'New broadcast msg'; + + fireEvent.change(inputElement, { target: { value: newMessage } }); + expect(mockSetDraft).toHaveBeenCalledWith(broadcastDest, newMessage); + + fireEvent.submit(formElement); + + expect(mockOnSend).toHaveBeenCalledWith(newMessage); + expect(mockClearDraft).toHaveBeenCalledWith(broadcastDest); }); }); \ No newline at end of file diff --git a/src/components/PageComponents/Messages/MessageInput.tsx b/src/components/PageComponents/Messages/MessageInput.tsx index a2383d0a..c229d8aa 100644 --- a/src/components/PageComponents/Messages/MessageInput.tsx +++ b/src/components/PageComponents/Messages/MessageInput.tsx @@ -1,54 +1,28 @@ import { Button } from "@components/UI/Button.tsx"; 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 { MessageState, MessageType, useMessageStore } from "@core/stores/messageStore.ts"; -import { debounce } from "@core/utils/debounce.ts"; +import { startTransition, useState } from "react"; +import { useMessageStore } from "@core/stores/messageStore/index.ts"; export interface MessageInputProps { + onSend: (message: string) => void; to: Types.Destination; - channel: Types.ChannelNumber; maxBytes: number; } export const MessageInput = ({ + onSend, to, - channel, maxBytes, }: MessageInputProps) => { - const { connection } = useDevice(); - const { setMessageState, activeChat, setDraft, getDraft, clearDraft } = useMessageStore(); - - const [localDraft, setLocalDraft] = useState(getDraft(to)); - const [messageBytes, setMessageBytes] = useState(0); - - const debouncedSetMessageDraft = useMemo( - () => debounce((value: string) => setDraft(to, value), 300), - [setDraft, to] - ); + const { setDraft, getDraft, clearDraft } = useMessageStore(); const calculateBytes = (text: string) => new Blob([text]).size; - const chatType = to === MessageType.Broadcast ? MessageType.Broadcast : MessageType.Direct; - - const sendText = useCallback(async (message: string) => { - try { - const messageId = await connection?.sendText(message, to, true, channel); - if (messageId !== undefined) { - setMessageState({ type: chatType, key: activeChat, messageId, newState: MessageState.Ack }); - } - // deno-lint-ignore no-explicit-any - } catch (e: any) { - setMessageState({ - type: chatType, - key: activeChat, - messageId: e?.id, - newState: MessageState.Failed, - }); - } - }, [channel, connection, setMessageState, to, activeChat, chatType]); + const initialDraft = getDraft(to); + const [localDraft, setLocalDraft] = useState(initialDraft); + const [messageBytes, setMessageBytes] = useState(() => calculateBytes(initialDraft)); const handleInputChange = (e: React.ChangeEvent) => { const newValue = e.target.value; @@ -56,27 +30,28 @@ export const MessageInput = ({ if (byteLength <= maxBytes) { setLocalDraft(newValue); - debouncedSetMessageDraft(newValue); setMessageBytes(byteLength); + setDraft(to, newValue); } }; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!localDraft.trim()) return; + // Reset bytes *before* sending (consider if onSend failure needs different handling) + setMessageBytes(0); startTransition(() => { - sendText(localDraft.trim()); + onSend(localDraft.trim()); setLocalDraft(""); clearDraft(to); - setMessageBytes(0); }); }; return (
    -
    -
    + +
    -
    ); -}; +}; \ No newline at end of file diff --git a/src/components/PageComponents/Messages/MessageItem.tsx b/src/components/PageComponents/Messages/MessageItem.tsx index 6c784cd7..ff696007 100644 --- a/src/components/PageComponents/Messages/MessageItem.tsx +++ b/src/components/PageComponents/Messages/MessageItem.tsx @@ -5,146 +5,138 @@ import { TooltipProvider, TooltipTrigger, } from "@components/UI/Tooltip.tsx"; -import { useDeviceStore } from "@core/stores/deviceStore.ts"; +import { useDevice } from "@core/stores/deviceStore.ts"; import { cn } from "@core/utils/cn.ts"; import { Avatar } from "@components/UI/Avatar.tsx"; import { AlertCircle, CheckCircle2, CircleEllipsis } from "lucide-react"; import type { LucideIcon } from "lucide-react"; import { ReactNode, useMemo } from "react"; -import { Message, MessageState } from "@core/stores/messageStore.ts"; -import { Protobuf } from "@meshtastic/js"; -import { MessageActionsMenu } from "@components/PageComponents/Messages/MessageActionsMenu.tsx"; +import { MessageState, useMessageStore } from "@core/stores/messageStore/index.ts"; +import { Protobuf, Types } from "@meshtastic/js"; +import { Message } from "@core/stores/messageStore/types.ts"; +// import { MessageActionsMenu } from "@components/PageComponents/Messages/MessageActionsMenu.tsx"; // Uncomment if needed later -interface MessageProps { - message: Message; - // locale?: string; // locale -} - -interface MessageStatus { - state: MessageState; +interface MessageStatusInfo { displayText: string; icon: LucideIcon; ariaLabel: string; + iconClassName?: string; } -const MESSAGE_STATUS: Record = { - [MessageState.Ack]: { state: MessageState.Ack, displayText: "Message delivered", icon: CheckCircle2, ariaLabel: "Message delivered" }, - [MessageState.Waiting]: { state: MessageState.Waiting, displayText: "Waiting for delivery", icon: CircleEllipsis, ariaLabel: "Sending message" }, - [MessageState.Failed]: { state: MessageState.Failed, displayText: "Delivery failed", icon: AlertCircle, ariaLabel: "Message delivery failed" }, +const MESSAGE_STATUS_MAP: Record = { + [MessageState.Ack]: { displayText: "Message delivered", icon: CheckCircle2, ariaLabel: "Message delivered", iconClassName: "text-green-500" }, + [MessageState.Waiting]: { displayText: "Waiting for delivery", icon: CircleEllipsis, ariaLabel: "Sending message", iconClassName: "text-slate-400" }, + [MessageState.Failed]: { displayText: "Delivery failed", icon: AlertCircle, ariaLabel: "Message delivery failed", iconClassName: "text-red-500 dark:text-red-400" }, }; -const getMessageStatus = (state: MessageState): MessageStatus => - MESSAGE_STATUS[state] ?? { state: MessageState.Failed, displayText: "Unknown state", icon: AlertCircle, ariaLabel: "Message status unknown" }; +const UNKNOWN_STATUS: MessageStatusInfo = { displayText: "Unknown state", icon: AlertCircle, ariaLabel: "Message status unknown", iconClassName: "text-red-500 dark:text-red-400" }; -const StatusTooltip = ({ status, children }: { status: MessageStatus; children: ReactNode }) => ( +const getMessageStatusInfo = (state: MessageState): MessageStatusInfo => + MESSAGE_STATUS_MAP[state] ?? UNKNOWN_STATUS; + +const StatusTooltip = ({ statusInfo, children }: { statusInfo: MessageStatusInfo; children: ReactNode }) => ( {children} - - {status.displayText} - + + {statusInfo.displayText} + ); -const StatusIcon = ({ status, className }: { status: MessageStatus; className?: string }) => { - const Icon = status.icon; - const iconClass = cn("w-3.5 h-3.5 shrink-0", className); - return ( - - - - - ); -}; +interface MessageItemProps { + message: Message; +} -const TimeDisplay = ({ date, className }: { date: number; className?: string }) => { - const _date = useMemo(() => new Date(date), [date]); - const locale = 'en-US'; // TODO: Make dynamic - const formattedTime = useMemo(() => _date.toLocaleTimeString(locale, { hour: 'numeric', minute: '2-digit', hour12: true }), [_date, locale]); - const fullDate = useMemo(() => _date.toLocaleString(locale, { dateStyle: 'medium', timeStyle: 'short' }), [_date, locale]); +export const MessageItem = ({ message }: MessageItemProps) => { + const { getNode } = useDevice(); + const { getMyNodeNum } = useMessageStore() + + const messageUser: Protobuf.Mesh.NodeInfo | null | undefined = useMemo(() => { + return message.from != null ? getNode(message.from) : null; + }, [getNode, message.from]); - return ( - - ); -}; -export const MessageItem = ({ message }: MessageProps) => { - const { getDevices } = useDeviceStore(); - - const messageUser: Protobuf.Mesh.NodeInfo | null = useMemo(() => { - if (message?.from === null || message?.from === undefined) return null; - const devices = getDevices(); - for (const device of devices) { - if (device.nodes.has(message.from)) { - return device.nodes.get(message.from) ?? null; - } - } - return null; - }, [getDevices, message.from]); - - const { shortName, displayName } = useMemo(() => { - const fallbackName = message.from + const myNodeNum = useMemo(() => getMyNodeNum(), [getMyNodeNum]); + const { displayName, shortName } = useMemo(() => { + const userIdHex = message.from.toString(16).toUpperCase().padStart(2, '0'); + const last4 = userIdHex.slice(-4); + const fallbackName = `Meshtastic ${last4}` const longName = messageUser?.user?.longName; - const shortName = messageUser?.user?.shortName ?? fallbackName; - const displayName = longName || fallbackName; - return { shortName, displayName }; + const derivedShortName = messageUser?.user?.shortName || fallbackName; + const derivedDisplayName = longName || derivedShortName; + return { displayName: derivedDisplayName, shortName: derivedShortName }; }, [messageUser, message.from]); - const messageStatus = getMessageStatus(message.state); - const messageText = message?.message ?? ""; - const messageDate = message?.date; - const isFailed = message.state === MessageState.Failed; + const messageStatusInfo = getMessageStatusInfo(message.state); + const StatusIconComponent = messageStatusInfo.icon; + + const messageDate = useMemo(() => message.date ? new Date(message.date) : null, [message.date]); + const locale = 'en-US'; // TODO: Make dynamic via props or context + + const formattedTime = useMemo(() => + messageDate?.toLocaleTimeString(locale, { hour: 'numeric', minute: '2-digit', hour12: true }) ?? '', + [messageDate, locale]); + + const fullDateTime = useMemo(() => + messageDate?.toLocaleString(locale, { dateStyle: 'medium', timeStyle: 'short' }) ?? '', + [messageDate, locale]); + + const isSender = myNodeNum !== undefined && message.from === myNodeNum; + const isOnPrimaryChannel = message.channel === Types.ChannelNumber.Primary; // Use the enum + const shouldShowStatusIcon = isSender && isOnPrimaryChannel; + const messageItemWrapperClass = cn( - "group w-full px-4 py-2 relative list-none", + "group w-full py-2 relative list-none", "rounded-md", "hover:bg-slate-300/15 dark:hover:bg-slate-600/20", "transition-colors duration-100 ease-in-out", ); + const dateTextStyle = "text-xs text-slate-500 dark:text-slate-400"; - const avatarSizeClass = "size-11"; - const gridGapClass = "gap-x-4"; - - const baseTextStyle = "text-sm text-gray-800 dark:text-gray-200"; - const nameTextStyle = "font-medium text-gray-900 dark:text-gray-100 mr-2"; - const dateTextStyle = "text-gray-500 dark:text-gray-400"; - const statusIconBaseColor = "text-gray-400 dark:text-gray-500"; - const statusIconFailedColor = "text-red-500 dark:text-red-400"; return (
  • -
    - - -
    - {messageDate != null ? ( -
    - - - -
    - ) : null} - -
    - {messageText} +
    + + +
    +
    + + {displayName} + + {messageDate && ( + + )} + {shouldShowStatusIcon && ( + + + + + )}
    + {message?.message && ( +
    + {message.message} +
    + )}
    - console.log("Reply to message:", message.messageId)} - /> + {/* Actions Menu Placeholder */} + {/*
    + console.log("Reply")} /> +
    */}
  • ); }; \ No newline at end of file diff --git a/src/components/PageComponents/Messages/TraceRoute.test.tsx b/src/components/PageComponents/Messages/TraceRoute.test.tsx index 977ef315..39443acb 100644 --- a/src/components/PageComponents/Messages/TraceRoute.test.tsx +++ b/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([ [ 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( ); - expect(screen.getByText(/^!63$/)).toBeInTheDocument(); // 99 in hex + expect(screen.getByText(/^!63$/)).toBeInTheDocument(); + expect(screen.getByText("↓ 5dB")).toBeInTheDocument(); + expect(screen.getByText("↓ 15dB")).toBeInTheDocument(); }); -}); +}); \ No newline at end of file diff --git a/src/components/PageComponents/Messages/TraceRoute.tsx b/src/components/PageComponents/Messages/TraceRoute.tsx index 13f8f08f..e67baacc 100644 --- a/src/components/PageComponents/Messages/TraceRoute.tsx +++ b/src/components/PageComponents/Messages/TraceRoute.tsx @@ -20,16 +20,16 @@ interface RoutePathProps { } const RoutePath = ({ title, startNode, endNode, path, snr }: RoutePathProps) => { - const { nodes } = useDevice(); + const { getNode } = useDevice(); return ( - +

    {title}

    {startNode?.user?.longName}

    ↓ {snr?.[0] ?? "??"}dB

    {path.map((hop, i) => ( - -

    {nodes.get(hop)?.user?.longName ?? `!${numberToHexUnpadded(hop)}`}

    + +

    {getNode(hop)?.user?.longName ?? `!${numberToHexUnpadded(hop)}`}

    ↓ {snr?.[i + 1] ?? "??"}dB

    ))} diff --git a/src/components/PageLayout.tsx b/src/components/PageLayout.tsx index 132e7bc4..40ae524e 100644 --- a/src/components/PageLayout.tsx +++ b/src/components/PageLayout.tsx @@ -1,77 +1,126 @@ +import React from 'react'; import { cn } from "@core/utils/cn.ts"; -import { AlignLeftIcon, type LucideIcon } from "lucide-react"; +import { type LucideIcon } from "lucide-react"; import Footer from "@components/UI/Footer.tsx"; import { Spinner } from "@components/UI/Spinner.tsx"; import { ErrorBoundary } from "react-error-boundary"; import { ErrorPage } from "@components/UI/ErrorPage.tsx"; +export interface ActionItem { + key: string; + icon: LucideIcon; + iconClasses?: string; + onClick: () => void; + disabled?: boolean; + isLoading?: boolean; + ariaLabel?: string; +} export interface PageLayoutProps { label: string; - noPadding?: boolean; + actions?: ActionItem[]; children: React.ReactNode; - className?: string; - actions?: { - icon: LucideIcon; - iconClasses?: string; - onClick: () => void; - disabled?: boolean; - isLoading?: boolean; - }[]; + leftBar?: React.ReactNode; + rightBar?: React.ReactNode; + noPadding?: boolean; + leftBarClassName?: string; + rightBarClassName?: string; + topBarClassName?: string; + contentClassName?: string; } export const PageLayout = ({ label, - noPadding, actions, - className, children, + leftBar, + rightBar, + noPadding, + leftBarClassName, + rightBarClassName, + topBarClassName, + contentClassName }: PageLayoutProps) => { return ( -
    -
    - -
    -
    - {label} -
    + {/* Header Content */} +
    + + {label} + +
    {actions?.map((action) => ( ))}
    -
    -
    -
    - {children} + + +
    + {children} +
    + + {/* Right Sidebar */} + {rightBar && ( + + )}
    ); -}; +}; \ No newline at end of file diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 68e0d525..caa541f4 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,148 +1,241 @@ +import React from "react"; import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx"; -import { SidebarButton } from "@components/UI/Sidebar/sidebarButton.tsx"; import { Subtle } from "@components/UI/Typography/Subtle.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; import type { Page } from "@core/stores/deviceStore.ts"; import { Spinner } from "@components/UI/Spinner.tsx"; +import { Avatar } from "@components/UI/Avatar.tsx"; import { - BatteryMediumIcon, + CircleChevronLeft, CpuIcon, - EditIcon, LayersIcon, type LucideIcon, MapIcon, MessageSquareIcon, + PenLine, + SearchIcon, SettingsIcon, - SidebarCloseIcon, - SidebarOpenIcon, UsersIcon, ZapIcon, } from "lucide-react"; -import { useState } from "react"; +import { cn } from "@core/utils/cn.ts"; +import { useSidebar } from "@core/stores/sidebarStore.tsx"; +import ThemeSwitcher from "@components/ThemeSwitcher.tsx"; +import { useAppStore } from "@core/stores/appStore.ts"; +import BatteryStatus from "@components/BatteryStatus.tsx"; +import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.tsx"; export interface SidebarProps { children?: React.ReactNode; } +interface NavLink { + name: string; + icon: LucideIcon; + page: Page; +} + +const CollapseToggleButton = () => { + const { isCollapsed, toggleSidebar } = useSidebar(); + const buttonLabel = isCollapsed ? "Open sidebar" : "Close sidebar"; + + return ( + + ); +} + export const Sidebar = ({ children }: SidebarProps) => { - const { hardware, nodes, metadata } = useDevice(); - const myNode = nodes.get(hardware.myNodeNum); + const { hardware, getNode, getNodesLength, metadata, activePage, setActivePage, setDialogOpen } = useDevice(); + const { setCommandPaletteOpen } = useAppStore(); + const myNode = getNode(hardware.myNodeNum); + const { isCollapsed } = useSidebar(); const myMetadata = metadata.get(0); - const { activePage, setActivePage, setDialogOpen } = useDevice(); - const [showSidebar, setShowSidebar] = useState(true); - - interface NavLink { - name: string; - icon: LucideIcon; - page: Page; - } const pages: NavLink[] = [ + { name: "Messages", icon: MessageSquareIcon, page: "messages" }, + { name: "Map", icon: MapIcon, page: "map" }, + { name: "Config", icon: SettingsIcon, page: "config" }, + { name: "Channels", icon: LayersIcon, page: "channels" }, { - name: "Messages", - icon: MessageSquareIcon, - page: "messages", - }, - { - name: "Map", - icon: MapIcon, - page: "map", - }, - { - name: "Config", - icon: SettingsIcon, - page: "config", - }, - { - name: "Channels", - icon: LayersIcon, - page: "channels", - }, - { - name: `Nodes (${Math.max(nodes.size - 1, 0)})`, + name: `Nodes (${Math.max(getNodesLength() - 1, 0)})`, icon: UsersIcon, page: "nodes", }, ]; - return showSidebar - ? ( -
    + return ( +
    + + +
    + Meshtastic Logo +

    + Meshtastic +

    +
    + + + {pages.map((link) => ( + { + if (myNode !== undefined) { + setActivePage(link.page); + } + }} + active={link.page === activePage} + disabled={myNode === undefined} + /> + ))} + + +
    + {children} +
    + +
    {myNode === undefined ? ( -
    +
    - Loading device info... + + Loading... +
    ) : ( <> -
    -
    - - {myNode.user?.shortName ?? "UNK"} - - {myNode.user?.longName ?? "UNK"} +
    + +

    + {myNode.user?.longName} +

    +
    + +
    +
    + +
    +
    + + {myNode.deviceMetrics?.voltage?.toPrecision(3) ?? "UNK"} volts
    +
    + + v{myMetadata?.firmwareVersion ?? "UNK"} +
    +
    +
    -
    -
    -
    - - - {myNode.deviceMetrics?.batteryLevel - ? myNode.deviceMetrics.batteryLevel > 100 - ? "Charging" - : `${myNode.deviceMetrics.batteryLevel}%` - : "UNK"} - -
    -
    - - - {myNode.deviceMetrics?.voltage?.toPrecision(3) ?? "UNK"} volts - -
    -
    - - v{myMetadata?.firmwareVersion ?? "UNK"} -
    -
    + )} - - - {pages.map((link) => ( - { - if (myNode !== undefined) { - setActivePage(link.page); - } - }} - active={link.page === activePage} - disabled={myNode === undefined} - /> - ))} - - {children} -
    - ) - : ( -
    -
    - ); -}; +
    + ); +}; \ No newline at end of file diff --git a/src/components/ThemeSwitcher.tsx b/src/components/ThemeSwitcher.tsx index ee075793..979c974b 100644 --- a/src/components/ThemeSwitcher.tsx +++ b/src/components/ThemeSwitcher.tsx @@ -12,9 +12,9 @@ export default function ThemeSwitcher({ const { theme, preference, setPreference } = useTheme(); const themeIcons = { - light: , - dark: , - system: , + light: , + dark: , + system: , }; const toggleTheme = () => { @@ -30,15 +30,15 @@ export default function ThemeSwitcher({
    diff --git a/src/components/UI/Command.tsx b/src/components/UI/Command.tsx index 28d0114c..d6673525 100644 --- a/src/components/UI/Command.tsx +++ b/src/components/UI/Command.tsx @@ -88,7 +88,7 @@ const CommandGroup = React.forwardRef< (({ className, ...props }, ref) => ( )); diff --git a/src/components/UI/Dialog.tsx b/src/components/UI/Dialog.tsx index ce56f70e..256e747b 100644 --- a/src/components/UI/Dialog.tsx +++ b/src/components/UI/Dialog.tsx @@ -44,7 +44,7 @@ const DialogContent = React.forwardRef< & { className?: string }) => ( (({ className, ...props }, ref) => ( )); @@ -118,7 +119,7 @@ const DialogDescription = React.forwardRef< >(({ className, ...props }, ref) => ( )); diff --git a/src/components/UI/Footer.tsx b/src/components/UI/Footer.tsx index 775b68d7..9eb56dec 100644 --- a/src/components/UI/Footer.tsx +++ b/src/components/UI/Footer.tsx @@ -7,7 +7,7 @@ type FooterProps = { const Footer = ({ className, ...props }: FooterProps) => { return (