diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa399d7c..5cfb85ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,6 +3,7 @@ name: CI on: push: branches: + - main - master permissions: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c3109a69..69a1e0ef 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -name: 'Release' +name: Release on: release: @@ -38,6 +38,12 @@ jobs: name: build path: dist/build.tar + - name: Attach build.tar to release + run: | + gh release upload ${{ github.event.release.tag_name }} dist/build.tar + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Set up QEMU uses: docker/setup-qemu-action@v3 diff --git a/deno.lock b/deno.lock index 7ff40a0e..061fb4a5 100644 --- a/deno.lock +++ b/deno.lock @@ -45,6 +45,7 @@ "npm:crypto-random-string@5": "5.0.0", "npm:gzipper@^8.2.1": "8.2.1", "npm:happy-dom@^17.4.4": "17.4.4", + "npm:idb-keyval@^6.2.1": "6.2.1", "npm:immer@^10.1.1": "10.1.1", "npm:js-cookie@^3.0.5": "3.0.5", "npm:lucide-react@0.486": "0.486.0_react@19.1.0", @@ -4492,6 +4493,9 @@ "https-browserify@1.0.0": { "integrity": "sha512-J+FkSdyD+0mA0N+81tMotaRMfSL9SGi+xpD3T6YApKsc3bGSXJlfXri3VyFOeYkfLRQisDk1W+jIFFKBeUBbBg==" }, + "idb-keyval@6.2.1": { + "integrity": "sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg==" + }, "idb@7.1.1": { "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==" }, @@ -6464,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", @@ -6491,6 +6496,7 @@ "npm:crypto-random-string@5", "npm:gzipper@^8.2.1", "npm:happy-dom@^17.4.4", + "npm:idb-keyval@^6.2.1", "npm:immer@^10.1.1", "npm:js-cookie@^3.0.5", "npm:lucide-react@0.486", diff --git a/package.json b/package.json index 3ebda1bb..a8f7b756 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "@radix-ui/react-scroll-area": "^1.2.3", "@radix-ui/react-select": "^2.1.6", "@radix-ui/react-separator": "^1.1.2", + "@radix-ui/react-slider": "^1.3.2", "@radix-ui/react-switch": "^1.1.3", "@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-toast": "^1.2.6", @@ -62,6 +63,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "crypto-random-string": "^5.0.0", + "idb-keyval": "^6.2.1", "immer": "^10.1.1", "js-cookie": "^3.0.5", "lucide-react": "^0.486.0", 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 289f07fc..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: ( @@ -219,10 +219,10 @@ export const CommandPalette = () => { }, }, { - label: "[WIP] Clear Messages", + label: "Clear All Stored Message", icon: EraserIcon, action() { - alert("This feature is not implemented"); + 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 new file mode 100644 index 00000000..67873be0 --- /dev/null +++ b/src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.test.tsx @@ -0,0 +1,69 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +// 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', () => ({ + useMessageStore: vi.fn(() => ({ + deleteAllMessages: vi.fn(), + })), +})); + +describe('DeleteMessagesDialog', () => { + const mockOnOpenChange = vi.fn(); + const mockClearAllMessages = vi.fn(); + + beforeEach(() => { + 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')).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', () => { + render(); + expect(screen.queryByText('Clear All Messages')).toBeNull(); + }); + + 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); + }); + + it('calls deleteAllMessages and onOpenChange with false when the clear messages button is clicked', () => { + 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 new file mode 100644 index 00000000..47fe0b92 --- /dev/null +++ b/src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx @@ -0,0 +1,63 @@ + +import { Button } from "@components/UI/Button.tsx"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@components/UI/Dialog.tsx"; +import { AlertTriangleIcon } from "lucide-react"; +import { useMessageStore } from "../../../core/stores/messageStore/index.ts"; + +export interface DeleteMessagesDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export const DeleteMessagesDialog = ({ + open, + onOpenChange, +}: DeleteMessagesDialogProps) => { + const { deleteAllMessages } = useMessageStore(); + const handleCloseDialog = () => { + onOpenChange(false); + }; + + return ( + + + + + + + Clear All Messages + + + This action will clear all message history. This cannot be undone. + Are you sure you want to continue? + + + + + + + + + ); +}; 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/DialogManager.tsx b/src/components/Dialog/DialogManager.tsx index 38e8ff3d..c7cb7605 100644 --- a/src/components/Dialog/DialogManager.tsx +++ b/src/components/Dialog/DialogManager.tsx @@ -10,6 +10,8 @@ import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDeta import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx"; import { RefreshKeysDialog } from "@components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx"; import { RebootOTADialog } from "@components/Dialog/RebootOTADialog.tsx"; +import { DeleteMessagesDialog } from "@components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx"; + export const DialogManager = () => { const { channels, config, dialog, setDialogOpen } = useDevice(); @@ -84,6 +86,12 @@ export const DialogManager = () => { setDialogOpen("rebootOTA", open); }} /> + { + setDialogOpen("deleteMessages", open); + }} + /> ); }; 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/NewDeviceDialog.tsx b/src/components/Dialog/NewDeviceDialog.tsx index 57cca565..b9fc6b1c 100644 --- a/src/components/Dialog/NewDeviceDialog.tsx +++ b/src/components/Dialog/NewDeviceDialog.tsx @@ -22,9 +22,12 @@ import { Subtle } from "@components/UI/Typography/Subtle.tsx"; import { AlertCircle } from "lucide-react"; import { Link } from "../UI/Typography/Link.tsx"; import { Fragment } from "react/jsx-runtime"; +import { useState } from "react"; export interface TabElementProps { closeDialog: () => void; + connectionInProgress: boolean; + setConnectionInProgress: (inProgress: boolean) => void; } export interface TabManifest { @@ -111,25 +114,28 @@ export const NewDeviceDialog = ({ open, onOpenChange, }: NewDeviceProps) => { + const [connectionInProgress, setConnectionInProgress] = + useState(false); const { unsupported } = useBrowserFeatureDetection(); + const tabs: TabManifest[] = [ { label: "HTTP", element: HTTP, - isDisabled: false, + isDisabled: connectionInProgress, }, { label: "Bluetooth", element: BLE, isDisabled: unsupported.includes("Web Bluetooth") || - unsupported.includes("Secure Context"), + unsupported.includes("Secure Context") || connectionInProgress, }, { label: "Serial", element: Serial, isDisabled: unsupported.includes("Web Serial") || - unsupported.includes("Secure Context"), + unsupported.includes("Secure Context") || connectionInProgress, }, ]; @@ -154,7 +160,7 @@ export const NewDeviceDialog = ({ {tab.isDisabled ? : null} - onOpenChange(false)} /> + onOpenChange(false)} setConnectionInProgress={setConnectionInProgress} connectionInProgress={connectionInProgress} /> ))} 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 a152d74e..dcf20f00 100644 --- a/src/components/Dialog/NodeOptionsDialog.tsx +++ b/src/components/Dialog/NodeOptionsDialog.tsx @@ -13,6 +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/index.ts"; export interface NodeOptionsDialogProps { node: Protobuf.Mesh.NodeInfo | undefined; @@ -29,23 +30,23 @@ export const NodeOptionsDialog = ({ const { setNodeNumToBeRemoved, setNodeNumDetails, - setChatType, - setActiveChat, } = useAppStore(); + const { setChatType, setActiveChat } = useMessageStore(); + + if (!node) return null; + const longName = node?.user?.longName ?? (node ? `!${numberToHexUnpadded(node?.num)}` : "Unknown"); const shortName = node?.user?.shortName ?? (node ? `${numberToHexUnpadded(node?.num).substring(0, 4)}` : "UNK"); function handleDirectMessage() { - if (!node) return; - setChatType("direct"); + setChatType(MessageType.Direct); setActiveChat(node.num); setActivePage("messages"); } function handleRequestPosition() { - if (!node) return; toast({ title: "Requesting position, please wait...", }); @@ -58,7 +59,6 @@ export const NodeOptionsDialog = ({ } function handleTraceroute() { - if (!node) return; toast({ title: "Sending Traceroute, please wait...", }); @@ -92,7 +92,7 @@ export const NodeOptionsDialog = ({ key="remove" variant="destructive" onClick={() => { - setNodeNumToBeRemoved(node.num); + setNodeNumToBeRemoved(node?.num); setDialogOpen("nodeRemoval", true); }} > @@ -103,7 +103,7 @@ export const NodeOptionsDialog = ({
    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 = ({ ({ - useRefreshKeysDialog: vi.fn(), -})); +vi.mock("@core/stores/messageStore"); +vi.mock("./useRefreshKeysDialog"); -describe("RefreshKeysDialog Component", () => { - let handleCloseDialogMock: Mock; - let handleNodeRemoveMock: Mock; - let onOpenChangeMock: Mock; +const mockUseMessageStore = vi.mocked(useMessageStore); +const mockUseRefreshKeysDialog = vi.mocked(useRefreshKeysDialog); - beforeEach(() => { - handleCloseDialogMock = vi.fn(); - handleNodeRemoveMock = vi.fn(); - onOpenChangeMock = vi.fn(); +const getInitialState = () => useDeviceStore.getInitialState?.() ?? { devices: new Map(), remoteDevices: new Map() }; - (useRefreshKeysDialog as Mock).mockReturnValue({ - handleCloseDialog: handleCloseDialogMock, - handleNodeRemove: handleNodeRemoveMock, - }); - }); +beforeEach(() => { + useDeviceStore.setState(getInitialState(), true); + vi.clearAllMocks(); +}); - it("renders the dialog with correct content", () => { - render(); - expect(screen.getByText("Keys Mismatch")).toBeInTheDocument(); - expect(screen.getByText("Request New Keys")).toBeInTheDocument(); - expect(screen.getByText("Dismiss")).toBeInTheDocument(); - }); +afterEach(() => { + vi.restoreAllMocks(); +}); - it("calls handleNodeRemove when 'Request New Keys' button is clicked", () => { - render(); - fireEvent.click(screen.getByText("Request New Keys")); - expect(handleNodeRemoveMock).toHaveBeenCalled(); - }); +test("renders dialog when there is a node error for the active chat", () => { + const deviceId = 1; + const nodeWithErrorNum = 12345; + const activeChatNum = nodeWithErrorNum; - it("calls handleCloseDialog when 'Dismiss' button is clicked", () => { - render(); - fireEvent.click(screen.getByText("Dismiss")); - expect(handleCloseDialogMock).toHaveBeenCalled(); - }); + 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"); + } - it("calls onOpenChange when dialog close button is clicked", () => { - render(); - fireEvent.click(screen.getByRole("button", { name: /close/i })); - expect(handleCloseDialogMock).toHaveBeenCalled(); + mockUseMessageStore.mockReturnValue({ activeChat: activeChatNum }); + const mockHandleClose = vi.fn(); + const mockHandleRemove = vi.fn(); + mockUseRefreshKeysDialog.mockReturnValue({ + handleCloseDialog: mockHandleClose, + handleNodeRemove: mockHandleRemove, }); - it("does not render when open is false", () => { - render(); - expect(screen.queryByText("Keys Mismatch")).not.toBeInTheDocument(); + render( + + + + ); + + 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(); +}); + +test("does not render dialog if no error exists for active chat", () => { + const deviceId = 1; + const activeChatNum = 54321; + + useDeviceStore.getState().addDevice(deviceId); + + 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( + + + + ); + + expect(container.firstChild).toBeNull(); }); diff --git a/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx b/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx index d2fc659d..98625ceb 100644 --- a/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx +++ b/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx @@ -8,6 +8,8 @@ import { 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/index.ts"; export interface RefreshKeysDialogProps { open: boolean; @@ -15,16 +17,30 @@ export interface RefreshKeysDialogProps { } export const RefreshKeysDialog = ({ open, onOpenChange }: RefreshKeysDialogProps) => { - + const { activeChat } = useMessageStore(); + const { nodeErrors, getNode } = useDevice(); const { handleCloseDialog, handleNodeRemove } = useRefreshKeysDialog(); + + const nodeErrorNum = nodeErrors.get(activeChat); + + if (!nodeErrorNum) { + return null; + } + + const nodeWithError = getNode(nodeErrorNum.node); + + const text = { + title: `Keys Mismatch - ${nodeWithError?.user?.longName ?? ""}`, + description: `Your node is unable to send a direct message to node: ${nodeWithError?.user?.longName ?? ""} (${nodeWithError?.user?.shortName ?? ""}). This is due to the remote node's current public key does not match the previously stored key for this node.`, + } return ( - Keys Mismatch + {text.title} - Your node is unable to send a direct message to this node. This is due to the remote node's current public key not matching the previously stored key for this node. + {text.description}
    • @@ -40,14 +56,12 @@ export const RefreshKeysDialog = ({ open, onOpenChange }: RefreshKeysDialogProps diff --git a/src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.test.ts b/src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.test.ts index b26d0165..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/appStore.ts", () => ({ - useAppStore: vi.fn(() => ({ activeChat: "chat-123" })), +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 821aade7..ba4f6740 100644 --- a/src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.ts +++ b/src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.ts @@ -1,10 +1,14 @@ import { useCallback } from "react"; -import { useAppStore } from "@core/stores/appStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts"; +import { useMessageStore } from "@core/stores/messageStore/index.ts"; export function useRefreshKeysDialog() { const { removeNode, setDialogOpen, clearNodeError, getNodeError } = useDevice(); - const { activeChat } = useAppStore(); + const { activeChat } = useMessageStore(); + + const handleCloseDialog = useCallback(() => { + setDialogOpen('refreshKeys', false); + }, [setDialogOpen]); const handleNodeRemove = useCallback(() => { const nodeWithError = getNodeError(activeChat); @@ -12,17 +16,12 @@ export function useRefreshKeysDialog() { 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" />
    ); -}; +}; \ No newline at end of file diff --git a/src/components/PageComponents/Connect/HTTP.tsx b/src/components/PageComponents/Connect/HTTP.tsx index 092b79bf..c50c94b5 100644 --- a/src/components/PageComponents/Connect/HTTP.tsx +++ b/src/components/PageComponents/Connect/HTTP.tsx @@ -1,6 +1,7 @@ import type { TabElementProps } from "@components/Dialog/NewDeviceDialog.tsx"; import { Button } from "@components/UI/Button.tsx"; import { Input } from "@components/UI/Input.tsx"; +import { Link } from "@components/UI/Typography/Link.tsx"; import { Label } from "@components/UI/Label.tsx"; import { Switch } from "@components/UI/Switch.tsx"; import { useAppStore } from "@core/stores/appStore.ts"; @@ -11,25 +12,28 @@ import { MeshDevice } from "@meshtastic/core"; import { TransportHTTP } from "@meshtastic/transport-http"; import { useState } from "react"; import { useForm, useController } from "react-hook-form"; +import { AlertTriangle } from "lucide-react"; +import { useMessageStore } from "../../../core/stores/messageStore/index.ts"; interface FormData { ip: string; tls: boolean; } -export const HTTP = ({ closeDialog }: TabElementProps) => { +export const HTTP = ({ closeDialog, setConnectionInProgress, connectionInProgress }: TabElementProps) => { const isURLHTTPS = location.protocol === "https:"; const { addDevice } = useDeviceStore(); + const messageStore = useMessageStore(); const { setSelectedDevice } = useAppStore(); const { control, handleSubmit, register } = useForm({ defaultValues: { ip: ["client.meshtastic.org", "localhost"].includes( - window.location.hostname, + globalThis.location.hostname, ) ? "meshtastic.local" - : window.location.host, + : globalThis.location.host, tls: isURLHTTPS ? true : false, }, }); @@ -38,49 +42,85 @@ export const HTTP = ({ closeDialog }: TabElementProps) => { field: { value: tlsValue, onChange: setTLS }, } = useController({ name: "tls", control }); - const [connectionInProgress, setConnectionInProgress] = useState(false); + const [connectionError, setConnectionError] = useState<{ host: string; secure: boolean } | null>(null); const onSubmit = handleSubmit(async (data) => { setConnectionInProgress(true); - const id = randId(); - const device = addDevice(id); - const transport = await TransportHTTP.create(data.ip, data.tls); - const connection = new MeshDevice(transport, id); - connection.configure(); - setSelectedDevice(id); - device.addConnection(connection); - subscribeAll(device, connection); - closeDialog(); + setConnectionError(null); + + try { + const id = randId(); + const transport = await TransportHTTP.create(data.ip, data.tls); + const device = addDevice(id); + const connection = new MeshDevice(transport, id); + connection.configure(); + setSelectedDevice(id); + device.addConnection(connection); + subscribeAll(device, connection, messageStore); + closeDialog(); + } catch (error) { + console.error("Connection error:", error); + // Capture all connection errors regardless of type + setConnectionError({ host: data.ip, secure: data.tls }); + setConnectionInProgress(false); + } }); return (
    -
    +
    -
    + + {connectionError && ( +
    +
    + +
    +

    + Connection Failed +

    +

    + Could not connect to the device. {connectionError.secure && "If using HTTPS, you may need to accept a self-signed certificate first. "} + Please open{" "} + + {`${connectionError.secure ? "https" : "http"}://${connectionError.host}`} + {" "} + in a new tab{connectionError.secure ? ", accept any TLS warnings if prompted, then try again" : ""}.{" "} + + Learn more + +

    +
    +
    +
    + )}
    diff --git a/src/components/PageComponents/Connect/Serial.tsx b/src/components/PageComponents/Connect/Serial.tsx index 28085cc2..b9cd7dd5 100644 --- a/src/components/PageComponents/Connect/Serial.tsx +++ b/src/components/PageComponents/Connect/Serial.tsx @@ -8,10 +8,12 @@ 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/index.ts"; -export const Serial = ({ closeDialog }: TabElementProps) => { +export const Serial = ({ setConnectionInProgress, closeDialog }: TabElementProps) => { const [serialPorts, setSerialPorts] = useState([]); const { addDevice } = useDeviceStore(); + const messageStore = useMessageStore() const { setSelectedDevice } = useAppStore(); const updateSerialPortList = useCallback(async () => { @@ -36,7 +38,7 @@ export const Serial = ({ closeDialog }: TabElementProps) => { const connection = new MeshDevice(transport, id); connection.configure(); device.addConnection(connection); - subscribeAll(device, connection); + subscribeAll(device, connection, messageStore); closeDialog(); }; @@ -50,8 +52,9 @@ export const Serial = ({ closeDialog }: TabElementProps) => {
    + + + Add Reaction + + + + + + + + + + Reply + + + + + +
    + ); +}; \ No newline at end of file diff --git a/src/components/PageComponents/Messages/MessageInput.test.tsx b/src/components/PageComponents/Messages/MessageInput.test.tsx index 632d9d29..97eb7542 100644 --- a/src/components/PageComponents/Messages/MessageInput.test.tsx +++ b/src/components/PageComponents/Messages/MessageInput.test.tsx @@ -1,152 +1,222 @@ -import { MessageInput } from '@components/PageComponents/Messages/MessageInput.tsx'; -import { useDevice } from "@core/stores/deviceStore.ts"; -import { vi, describe, it, expect, beforeEach, Mock } from 'vitest'; -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; - -vi.mock("@core/stores/deviceStore.ts", () => ({ - useDevice: vi.fn(), +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { MessageInput, MessageInputProps } from './MessageInput.tsx'; +import { Types } from '@meshtastic/core'; + +vi.mock('@components/UI/Button.tsx', () => ({ + Button: vi.fn(({ type, className, children, onClick, onSubmit, variant, ...rest }) => ( + + )), })); -vi.mock("@core/utils/debounce.ts", () => ({ - debounce: (fn: () => void) => fn, +vi.mock('@components/UI/Input.tsx', () => ({ + Input: vi.fn(({ autoFocus, minLength, name, placeholder, value, onChange }) => ( + + )), })); -vi.mock("@components/UI/Button.tsx", () => ({ - Button: ({ children, ...props }: { children: React.ReactNode }) => +const mockSetDraft = vi.fn(); +const mockGetDraft = vi.fn(); +const mockClearDraft = vi.fn(); + +vi.mock('@core/stores/messageStore', () => ({ + useMessageStore: vi.fn(() => ({ + setDraft: mockSetDraft, + getDraft: mockGetDraft, + clearDraft: mockClearDraft, + })), + MessageState: { + Ack: 'ack', + Waiting: 'waiting', + Failed: 'failed', + }, + MessageType: { + Direct: 'direct', + Broadcast: 'broadcast', + }, })); -vi.mock("@components/UI/Input.tsx", () => ({ - Input: (props: any) => +vi.mock('lucide-react', () => ({ + SendIcon: vi.fn(() => ), })); -vi.mock("lucide-react", () => ({ - SendIcon: () =>
    Send
    -})); - -// TODO: getting an error with this test -describe('MessageInput Component', () => { - const mockProps = { - to: "broadcast" as const, - channel: 0 as const, - maxBytes: 100, +describe('MessageInput', () => { + const mockOnSend = vi.fn(); + const defaultProps: MessageInputProps = { + onSend: mockOnSend, + to: 123, + maxBytes: 256, }; - const mockSetMessageDraft = vi.fn(); - const mockSetMessageState = vi.fn(); - const mockSendText = vi.fn().mockResolvedValue(123); - beforeEach(() => { vi.clearAllMocks(); - (useDevice as Mock).mockReturnValue({ - connection: { - sendText: mockSendText, - }, - setMessageState: mockSetMessageState, - messageDraft: "", - setMessageDraft: mockSetMessageDraft, - hardware: { - myNodeNum: 1234567890, - }, - }); + mockGetDraft.mockReturnValue(''); }); - it('renders correctly with initial state', () => { - render(); + const renderComponent = (props: Partial = {}) => { + render(); + }; + 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(); + }); - expect(screen.getByText('0/100')).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; + 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('updates local draft and byte count when typing', () => { - render(); + 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; - const inputField = screen.getByPlaceholderText('Enter Message'); - fireEvent.change(inputField, { target: { value: 'Hello' } }) + fireEvent.change(inputElement, { target: { value: testMessage } }); - expect(screen.getByText('5/100')).toBeInTheDocument(); - expect(inputField).toHaveValue('Hello'); - expect(mockSetMessageDraft).toHaveBeenCalledWith('Hello'); + 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.skip('does not allow input exceeding max bytes', () => { - render(); + 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'; - const inputField = screen.getByPlaceholderText('Enter Message'); + fireEvent.change(inputElement, { target: { value: initialValue } }); + expect((inputElement as HTMLInputElement).value).toBe(initialValue); + expect(mockSetDraft).toHaveBeenCalledWith(defaultProps.to, initialValue); + mockSetDraft.mockClear(); - expect(screen.getByText('0/100')).toBeInTheDocument(); + fireEvent.change(inputElement, { target: { value: excessiveValue } }); - userEvent.type(inputField, 'Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis p') + expect((inputElement as HTMLInputElement).value).toBe(initialValue); + expect(screen.getByTestId('byte-counter')).toHaveTextContent(`${smallMaxBytes}/${smallMaxBytes}`); + expect(mockSetDraft).not.toHaveBeenCalled(); + }); - expect(screen.getByText('100/100')).toBeInTheDocument(); - expect(inputField).toHaveValue('Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean m'); + 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(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 message and resets form when submitting', async () => { - try { - render(); + 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!'; - const inputField = screen.getByPlaceholderText('Enter Message'); - const submitButton = screen.getByText('Send'); + fireEvent.change(inputElement, { target: { value: testMessageWithWhitespace } }); + fireEvent.submit(formElement); - fireEvent.change(inputField, { target: { value: 'Test Message' } }); - fireEvent.click(submitButton); + await waitFor(() => { + expect(mockOnSend).toHaveBeenCalledTimes(1); + expect(mockOnSend).toHaveBeenCalledWith(expectedTrimmedMessage); + expect(mockClearDraft).toHaveBeenCalledWith(defaultProps.to); + }); + }); - const form = screen.getByRole('form'); - fireEvent.submit(form); + 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(mockSendText).toHaveBeenCalledWith('Test message', 'broadcast', true, 0); + expect((inputElement as HTMLInputElement).value).toBe(''); - await waitFor(() => { - expect(mockSetMessageState).toHaveBeenCalledWith( - 'broadcast', - 0, - 'broadcast', - 1234567890, - 123, - 'ack' - ); + fireEvent.submit(formElement); - }); + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 50)); + }); - expect(inputField).toHaveValue(''); - expect(screen.getByText('0/100')).toBeInTheDocument(); - expect(mockSetMessageDraft).toHaveBeenCalledWith(''); - } catch (e) { - console.error(e); - } + expect(mockOnSend).not.toHaveBeenCalled(); + expect(mockClearDraft).not.toHaveBeenCalled(); }); - it('prevents sending empty messages', () => { - render(); - const form = screen.getByPlaceholderText('Enter Message') - fireEvent.submit(form); + 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 '; - expect(mockSendText).not.toHaveBeenCalled(); - }); + fireEvent.change(inputElement, { target: { value: whitespaceMessage } }); + expect((inputElement as HTMLInputElement).value).toBe(whitespaceMessage); - it('initializes with existing message draft', () => { - (useDevice as Mock).mockReturnValue({ - connection: { - sendText: mockSendText, - }, - setMessageState: mockSetMessageState, - messageDraft: "Existing draft", - setMessageDraft: mockSetMessageDraft, - isQueueingMessages: false, - queueStatus: { free: 10 }, - hardware: { - myNodeNum: 1234567890, - }, + fireEvent.submit(formElement); + + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 50)); }); - render(); + 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); - const inputField = screen.getByRole('textbox'); + fireEvent.submit(formElement); - expect(inputField).toHaveValue('Existing draft'); + 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 b885afaf..c229d8aa 100644 --- a/src/components/PageComponents/Messages/MessageInput.tsx +++ b/src/components/PageComponents/Messages/MessageInput.tsx @@ -1,101 +1,57 @@ -import { debounce } from "@core/utils/debounce.ts"; 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 { 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, - setMessageState, - messageDraft, - setMessageDraft, - isQueueingMessages, - queueStatus, - hardware, - } = useDevice(); - const myNodeNum = hardware.myNodeNum; - const [localDraft, setLocalDraft] = useState(messageDraft); - const [messageBytes, setMessageBytes] = useState(0); + const { setDraft, getDraft, clearDraft } = useMessageStore(); - const debouncedSetMessageDraft = useMemo( - () => debounce(setMessageDraft, 300), - [setMessageDraft], - ); - - // sends the message to the selected destination - const sendText = useCallback( - async (message: string) => { + const calculateBytes = (text: string) => new Blob([text]).size; - await connection - ?.sendText(message, to, true, channel) - .then((id: number) => - setMessageState( - to === "broadcast" ? "broadcast" : "direct", - channel, - to as number, - myNodeNum, - id, - "ack", - ) - ) - .catch((e: Types.PacketError) => - setMessageState( - to === "broadcast" ? "broadcast" : "direct", - channel, - to as number, - myNodeNum, - e.id, - e.error, - ) - ); - }, - [channel, connection, myNodeNum, setMessageState, to, queueStatus], - ); + 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; - const byteLength = new Blob([newValue]).size; + const byteLength = calculateBytes(newValue); 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(() => { + onSend(localDraft.trim()); + setLocalDraft(""); + clearDraft(to); + }); + }; + return (
    - { - // prevent user from sending blank/empty message - if (localDraft === "") return; - const message = formData.get("messageInput") as string; - startTransition(() => { - if (!isQueueingMessages) { - sendText(message); - setLocalDraft(""); - setMessageDraft(""); - setMessageBytes(0); - } - - }); - }} - > -
    + +
    -
    ); -}; +}; \ No newline at end of file diff --git a/src/components/PageComponents/Messages/MessageItem.tsx b/src/components/PageComponents/Messages/MessageItem.tsx index 384aaefc..ff696007 100644 --- a/src/components/PageComponents/Messages/MessageItem.tsx +++ b/src/components/PageComponents/Messages/MessageItem.tsx @@ -5,122 +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/services/types.ts"; +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 { - lastMsgSameUser: boolean; - message: Message; -} - -interface MessageStatus { - state: MessageState; +interface MessageStatusInfo { displayText: string; icon: LucideIcon; + ariaLabel: string; + iconClassName?: string; } -const MESSAGE_STATUS: Record = { - ack: { state: "ack", displayText: "Message delivered", icon: CheckCircle2 }, - waiting: { state: "waiting", displayText: "Waiting for delivery", icon: CircleEllipsis }, - failed: { state: "failed", displayText: "Delivery failed", icon: AlertCircle }, +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: "failed", displayText: "Unknown error", icon: AlertCircle }; +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, ...otherProps }: { status: MessageStatus; className?: string }) => { - const isFailed = status.state === "failed"; - const iconClass = cn("text-slate-500 dark:text-slate-400 w-4 h-4 shrink-0", className); - const Icon = status.icon; +interface MessageItemProps { + message: Message; +} - return ( - - - - ); -}; +export const MessageItem = ({ message }: MessageItemProps) => { + const { getNode } = useDevice(); + const { getMyNodeNum } = useMessageStore() -const getMessageTextStyles = (status: MessageStatus) => { - const isAcknowledged = status.state === "ack"; - const isFailed = status.state === "failed"; + const messageUser: Protobuf.Mesh.NodeInfo | null | undefined = useMemo(() => { + return message.from != null ? getNode(message.from) : null; + }, [getNode, message.from]); - return cn( - "break-words overflow-hidden", - isAcknowledged ? "text-slate-900 dark:text-white" : "text-slate-900 dark:text-slate-400", - isFailed && "text-red-500 dark:text-red-500", - ); -}; -const TimeDisplay = ({ date, className }: { date: Date; className?: string }) => ( -
    - {date.toLocaleDateString()} - - {date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })} - -
    -); + 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 derivedShortName = messageUser?.user?.shortName || fallbackName; + const derivedDisplayName = longName || derivedShortName; + return { displayName: derivedDisplayName, shortName: derivedShortName }; + }, [messageUser, message.from]); -export const MessageItem = ({ lastMsgSameUser, message }: MessageProps) => { - const { getDevices } = useDeviceStore(); + const messageStatusInfo = getMessageStatusInfo(message.state); + const StatusIconComponent = messageStatusInfo.icon; - const isDeviceUser = useMemo( - () => - getDevices() - .map((device) => device.nodes.get(device.hardware.myNodeNum)?.num) - .includes(message.from), - [getDevices, message.from], - ); + 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 messageUser = message?.from - ? getDevices().find((device) => device.nodes.has(message.from))?.nodes.get(message.from) - : null; + 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 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 messageStatus = getMessageStatus(message.state); - const messageTextClass = getMessageTextStyles(messageStatus); return ( -
    -
    -
    - {!lastMsgSameUser && ( -
    - -
    - - {messageUser?.user?.longName} +
  • +
    + + +
    +
    + + {displayName} + + {messageDate && ( + + )} + {shouldShowStatusIcon && ( + + + -
    + + )} +
    + + {message?.message && ( +
    + {message.message}
    )}
    - -
    -
    {message.message}
    - -
  • -
    + {/* 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 2ce7bd9c..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 (${nodes.size - 1})`, + 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({ ); }, ); Button.displayName = "Button"; -export { Button, buttonVariants }; +export { Button, buttonVariants }; \ No newline at end of file diff --git a/src/components/UI/Checkbox/index.tsx b/src/components/UI/Checkbox/index.tsx index 59240c79..ac6ff441 100644 --- a/src/components/UI/Checkbox/index.tsx +++ b/src/components/UI/Checkbox/index.tsx @@ -1,4 +1,4 @@ -import { useState, useId } from "react"; +import { useState, useEffect, useId } from "react"; import { Check } from "lucide-react"; import { Label } from "@components/UI/Label.tsx"; import { cn } from "@core/utils/cn.ts"; @@ -32,6 +32,11 @@ export function Checkbox({ const [isChecked, setIsChecked] = useState(checked || false); + // Make sure setIsChecked state updates with checked + useEffect(() => { + setIsChecked(checked || false); + }, [checked]); + const handleToggle = () => { if (disabled) return; @@ -66,7 +71,7 @@ export function Checkbox({ > {isChecked && (
    - +
    )}
    diff --git a/src/components/UI/Command.tsx b/src/components/UI/Command.tsx index d230d326..d6673525 100644 --- a/src/components/UI/Command.tsx +++ b/src/components/UI/Command.tsx @@ -41,7 +41,7 @@ const CommandInput = React.forwardRef< className="flex items-center border-b border-b-slate-100 px-4 dark:border-b-slate-700" cmdk-input-wrapper="" > - + (({ className, ...props }, ref) => ( )); diff --git a/src/components/UI/Dialog.tsx b/src/components/UI/Dialog.tsx index 442af95e..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 (