diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5cfb85ec..fa399d7c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,6 @@ name: CI on: push: branches: - - main - master permissions: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 69a1e0ef..c3109a69 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -name: Release +name: 'Release' on: release: @@ -38,12 +38,6 @@ 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 061fb4a5..7ff40a0e 100644 --- a/deno.lock +++ b/deno.lock @@ -45,7 +45,6 @@ "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", @@ -4493,9 +4492,6 @@ "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==" }, @@ -6468,7 +6464,6 @@ "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", @@ -6496,7 +6491,6 @@ "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 a8f7b756..3ebda1bb 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,6 @@ "@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", @@ -63,7 +62,6 @@ "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 deleted file mode 100644 index e6863f6a..00000000 --- a/public/Logo.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - -Created with Fabric.js 4.6.0 - - - - - - - - - - - - \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index db735aef..35152fd1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,6 @@ 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"; @@ -13,8 +14,6 @@ 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 => { @@ -24,9 +23,6 @@ export const App = (): JSX.Element => { const device = getDevice(selectedDevice); - // Sets up light/dark mode based on user preferences or system settings - useTheme() - return ( { /> -
- -
+
+
+ +
{device ? ( -
+
@@ -56,9 +53,9 @@ export const App = (): JSX.Element => { )}
- +
- + ); }; diff --git a/src/components/BatteryStatus.tsx b/src/components/BatteryStatus.tsx deleted file mode 100644 index bd6022c9..00000000 --- a/src/components/BatteryStatus.tsx +++ /dev/null @@ -1,86 +0,0 @@ -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 11612984..289f07fc 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, getNode, connection } = useDevice(); + const { setDialogOpen, setActivePage, 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: - getNode(device.hardware.myNodeNum)?.user?.longName ?? + device.nodes.get(device.hardware.myNodeNum)?.user?.longName ?? device.hardware.myNodeNum.toString(), icon: ( @@ -219,10 +219,10 @@ export const CommandPalette = () => { }, }, { - label: "Clear All Stored Message", + label: "[WIP] Clear Messages", icon: EraserIcon, action() { - setDialogOpen("deleteMessages", true); + alert("This feature is not implemented"); }, }, ], @@ -262,7 +262,7 @@ export const CommandPalette = () => { type="button" onClick={() => togglePinnedItem(group.label)} className={cn( - "transition-all duration-300 scale-100 cursor-pointer p-2 focus:*:data-label:opacity-100" + "transition-all duration-300 scale-100 cursor-pointer m-0.5 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 new file mode 100644 index 00000000..872b9c04 --- /dev/null +++ b/src/components/DeviceSelector.tsx @@ -0,0 +1,76 @@ +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 new file mode 100644 index 00000000..62c2bc2a --- /dev/null +++ b/src/components/DeviceSelectorButton.tsx @@ -0,0 +1,25 @@ +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 deleted file mode 100644 index 67873be0..00000000 --- a/src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.test.tsx +++ /dev/null @@ -1,69 +0,0 @@ -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 deleted file mode 100644 index 47fe0b92..00000000 --- a/src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx +++ /dev/null @@ -1,63 +0,0 @@ - -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 c41c81ba..765e4097 100644 --- a/src/components/Dialog/DeviceNameDialog.tsx +++ b/src/components/Dialog/DeviceNameDialog.tsx @@ -10,11 +10,10 @@ 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; @@ -25,44 +24,32 @@ 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, getNode, connection } = useDevice(); - const myNode = getNode(hardware.myNodeNum); + const { hardware, nodes, connection } = useDevice(); - const defaultValues = { - longName: myNode?.user?.longName ?? "Unknown", - shortName: myNode?.user?.shortName ?? "??", - }; + const myNode = nodes.get(hardware.myNodeNum); - const { getValues, setValue, reset, control, handleSubmit } = useForm({ - values: defaultValues, + const { register, handleSubmit } = useForm({ + values: { + longName: myNode?.user?.longName ?? "Unknown", + shortName: myNode?.user?.shortName ?? "Unknown", + }, }); - 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 ( @@ -73,51 +60,22 @@ 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 c7cb7605..38e8ff3d 100644 --- a/src/components/Dialog/DialogManager.tsx +++ b/src/components/Dialog/DialogManager.tsx @@ -10,8 +10,6 @@ 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(); @@ -86,12 +84,6 @@ export const DialogManager = () => { setDialogOpen("rebootOTA", open); }} /> - { - setDialogOpen("deleteMessages", open); - }} - /> ); }; diff --git a/src/components/Dialog/ImportDialog.tsx b/src/components/Dialog/ImportDialog.tsx index 242502cd..805d2f3e 100644 --- a/src/components/Dialog/ImportDialog.tsx +++ b/src/components/Dialog/ImportDialog.tsx @@ -109,6 +109,7 @@ export const ImportDialog = ({ { setImportDialogInput(e.target.value); }} diff --git a/src/components/Dialog/LocationResponseDialog.tsx b/src/components/Dialog/LocationResponseDialog.tsx index 4625fa87..c4df3761 100644 --- a/src/components/Dialog/LocationResponseDialog.tsx +++ b/src/components/Dialog/LocationResponseDialog.tsx @@ -21,9 +21,9 @@ export const LocationResponseDialog = ({ open, onOpenChange, }: LocationResponseDialogProps) => { - const { getNode } = useDevice(); + const { nodes } = useDevice(); - const from = getNode(location?.from ?? 0); + const from = nodes.get(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 b9fc6b1c..57cca565 100644 --- a/src/components/Dialog/NewDeviceDialog.tsx +++ b/src/components/Dialog/NewDeviceDialog.tsx @@ -22,12 +22,9 @@ 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 { @@ -114,28 +111,25 @@ export const NewDeviceDialog = ({ open, onOpenChange, }: NewDeviceProps) => { - const [connectionInProgress, setConnectionInProgress] = - useState(false); const { unsupported } = useBrowserFeatureDetection(); - const tabs: TabManifest[] = [ { label: "HTTP", element: HTTP, - isDisabled: connectionInProgress, + isDisabled: false, }, { label: "Bluetooth", element: BLE, isDisabled: unsupported.includes("Web Bluetooth") || - unsupported.includes("Secure Context") || connectionInProgress, + unsupported.includes("Secure Context"), }, { label: "Serial", element: Serial, isDisabled: unsupported.includes("Web Serial") || - unsupported.includes("Secure Context") || connectionInProgress, + unsupported.includes("Secure Context"), }, ]; @@ -160,7 +154,7 @@ export const NewDeviceDialog = ({ {tab.isDisabled ? : null} - onOpenChange(false)} setConnectionInProgress={setConnectionInProgress} connectionInProgress={connectionInProgress} /> + onOpenChange(false)} /> ))} diff --git a/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.test.tsx b/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.test.tsx index 2b2edc23..d8d1bea9 100644 --- a/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.test.tsx +++ b/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.test.tsx @@ -1,17 +1,12 @@ -import { describe, it, vi, expect, beforeEach } from "vitest"; +import { describe, it, vi, expect, beforeEach, Mock } 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, @@ -34,21 +29,17 @@ describe("NodeDetailsDialog", () => { voltage: 4.2, uptimeSeconds: 3600, }, - } as unknown as Protobuf.Mesh.NodeInfo; + }; beforeEach(() => { + // Reset mocks before each test vi.resetAllMocks(); - mockUseDevice.mockReturnValue({ - getNode: (nodeNum: number) => { - if (nodeNum === 1234) { - return mockDevice; - } - return undefined; - }, + (useDevice as Mock).mockReturnValue({ + nodes: new Map([[1234, mockDevice]]), }); - mockUseAppStore.mockReturnValue({ + (useAppStore as unknown as Mock).mockReturnValue({ nodeNumDetails: 1234, }); }); @@ -56,87 +47,27 @@ describe("NodeDetailsDialog", () => { it("renders node details correctly", () => { render( { }} />); - expect(screen.getByText(/Node Details for Test Node \(TN\)/i)).toBeInTheDocument(); + expect(screen.getByText(/Node Details for Test Node/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(/All Raw Metrics:/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(); }); it("renders null if device is not found", () => { - const requestedNodeNum = 5678; - - mockUseAppStore.mockReturnValue({ - nodeNumDetails: requestedNodeNum, + (useDevice as Mock).mockReturnValue({ + nodes: new Map(), }); - 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(); + expect(screen.queryByText(/Node Details for/i)).not.toBeInTheDocument(); }); - -}); \ No newline at end of file +}); diff --git a/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx b/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx index 9001c5f1..f010fc67 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 { getNode } = useDevice(); + const { nodes } = useDevice(); const { nodeNumDetails } = useAppStore(); - const device = getNode(nodeNumDetails); + const device = nodes.get(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 dcf20f00..a152d74e 100644 --- a/src/components/Dialog/NodeOptionsDialog.tsx +++ b/src/components/Dialog/NodeOptionsDialog.tsx @@ -13,7 +13,6 @@ 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; @@ -30,23 +29,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() { - setChatType(MessageType.Direct); + if (!node) return; + setChatType("direct"); setActiveChat(node.num); setActivePage("messages"); } function handleRequestPosition() { + if (!node) return; toast({ title: "Requesting position, please wait...", }); @@ -59,6 +58,7 @@ 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 6eb86356..c4eecfe4 100644 --- a/src/components/Dialog/QRDialog.tsx +++ b/src/components/Dialog/QRDialog.tsx @@ -133,8 +133,8 @@ export const QRDialog = ({ ({ + useRefreshKeysDialog: vi.fn(), +})); -const mockUseMessageStore = vi.mocked(useMessageStore); -const mockUseRefreshKeysDialog = vi.mocked(useRefreshKeysDialog); +describe("RefreshKeysDialog Component", () => { + let handleCloseDialogMock: Mock; + let handleNodeRemoveMock: Mock; + let onOpenChangeMock: Mock; -const getInitialState = () => useDeviceStore.getInitialState?.() ?? { devices: new Map(), remoteDevices: new Map() }; + beforeEach(() => { + handleCloseDialogMock = vi.fn(); + handleNodeRemoveMock = vi.fn(); + onOpenChangeMock = vi.fn(); -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, + (useRefreshKeysDialog as Mock).mockReturnValue({ + handleCloseDialog: handleCloseDialogMock, + handleNodeRemove: handleNodeRemoveMock, + }); }); - 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); + 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(); + }); - const currentDeviceState = useDeviceStore.getState().getDevice(deviceId); - if (!currentDeviceState) throw new Error("Device not found"); + it("calls handleNodeRemove when 'Request New Keys' button is clicked", () => { + render(); + fireEvent.click(screen.getByText("Request New Keys")); + expect(handleNodeRemoveMock).toHaveBeenCalled(); + }); - mockUseMessageStore.mockReturnValue({ activeChat: activeChatNum }); - mockUseRefreshKeysDialog.mockReturnValue({ - handleCloseDialog: vi.fn(), - handleNodeRemove: vi.fn(), + it("calls handleCloseDialog when 'Dismiss' button is clicked", () => { + render(); + fireEvent.click(screen.getByText("Dismiss")); + expect(handleCloseDialogMock).toHaveBeenCalled(); }); - const { container } = render( - - - - ); + it("calls onOpenChange when dialog close button is clicked", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /close/i })); + expect(handleCloseDialogMock).toHaveBeenCalled(); + }); - expect(container.firstChild).toBeNull(); + it("does not render when open is false", () => { + render(); + expect(screen.queryByText("Keys Mismatch")).not.toBeInTheDocument(); + }); }); diff --git a/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx b/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx index 98625ceb..d2fc659d 100644 --- a/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx +++ b/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx @@ -8,8 +8,6 @@ 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; @@ -17,30 +15,16 @@ 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.`, - } + const { handleCloseDialog, handleNodeRemove } = useRefreshKeysDialog(); return ( - {text.title} + Keys Mismatch - {text.description} + 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.
    • @@ -56,12 +40,14 @@ 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 b50eaee6..b26d0165 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", () => ({ - useMessageStore: vi.fn(() => ({ activeChat: "chat-123" })), +vi.mock("@core/stores/appStore.ts", () => ({ + useAppStore: vi.fn(() => ({ activeChat: "chat-123" })), })); -vi.mock("@core/stores/deviceStore", () => ({ + +vi.mock("@core/stores/deviceStore.ts", () => ({ useDevice: vi.fn(() => ({ removeNode: vi.fn(), setDialogOpen: vi.fn(), @@ -23,50 +23,46 @@ describe("useRefreshKeysDialog Hook", () => { let clearNodeErrorMock: Mock; beforeEach(() => { - vi.clearAllMocks(); - removeNodeMock = vi.fn(); setDialogOpenMock = vi.fn(); - getNodeErrorMock = vi.fn().mockReturnValue(undefined); + getNodeErrorMock = vi.fn(); clearNodeErrorMock = vi.fn(); - vi.mocked(useDevice).mockReturnValue({ + (useDevice as Mock).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(); }); - expect(getNodeErrorMock).toHaveBeenCalledTimes(1); + act(() => { + result.current.handleNodeRemove(); + }); + 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(); }); - expect(getNodeErrorMock).toHaveBeenCalledTimes(1); - expect(getNodeErrorMock).toHaveBeenCalledWith("chat-123"); - expect(clearNodeErrorMock).not.toHaveBeenCalled(); + act(() => { + result.current.handleNodeRemove(); + }); + expect(removeNodeMock).not.toHaveBeenCalled(); expect(setDialogOpenMock).not.toHaveBeenCalled(); + expect(clearNodeErrorMock).not.toHaveBeenCalled(); }); it("handleCloseDialog should close the dialog", () => { @@ -76,7 +72,6 @@ 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 ba4f6740..821aade7 100644 --- a/src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.ts +++ b/src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.ts @@ -1,14 +1,10 @@ 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 } = useMessageStore(); - - const handleCloseDialog = useCallback(() => { - setDialogOpen('refreshKeys', false); - }, [setDialogOpen]); + const { activeChat } = useAppStore(); const handleNodeRemove = useCallback(() => { const nodeWithError = getNodeError(activeChat); @@ -16,12 +12,17 @@ export function useRefreshKeysDialog() { return; } clearNodeError(activeChat); - handleCloseDialog(); + handleCloseDialog();; return removeNode(nodeWithError?.node); - }, [activeChat, clearNodeError, getNodeError, removeNode, handleCloseDialog]); + }, [activeChat, clearNodeError, setDialogOpen, removeNode]); + + const handleCloseDialog = useCallback(() => { + setDialogOpen('refreshKeys', false); + }, [setDialogOpen]) return { handleCloseDialog, handleNodeRemove }; + } \ No newline at end of file diff --git a/src/components/Dialog/RemoveNodeDialog.tsx b/src/components/Dialog/RemoveNodeDialog.tsx index 66c42dc6..66297f23 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, getNode, removeNode } = useDevice(); + const { connection, nodes, 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 ddc50edb..28fdb381 100644 --- a/src/components/Dialog/ShutdownDialog.tsx +++ b/src/components/Dialog/ShutdownDialog.tsx @@ -41,6 +41,7 @@ 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 c50c94b5..092b79bf 100644 --- a/src/components/PageComponents/Connect/HTTP.tsx +++ b/src/components/PageComponents/Connect/HTTP.tsx @@ -1,7 +1,6 @@ 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"; @@ -12,28 +11,25 @@ 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, setConnectionInProgress, connectionInProgress }: TabElementProps) => { +export const HTTP = ({ closeDialog }: 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( - globalThis.location.hostname, + window.location.hostname, ) ? "meshtastic.local" - : globalThis.location.host, + : window.location.host, tls: isURLHTTPS ? true : false, }, }); @@ -42,85 +38,49 @@ export const HTTP = ({ closeDialog, setConnectionInProgress, connectionInProgres field: { value: tlsValue, onChange: setTLS }, } = useController({ name: "tls", control }); - const [connectionError, setConnectionError] = useState<{ host: string; secure: boolean } | null>(null); + const [connectionInProgress, setConnectionInProgress] = useState(false); const onSubmit = handleSubmit(async (data) => { setConnectionInProgress(true); - 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); - } + 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(); }); 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 b9cd7dd5..28085cc2 100644 --- a/src/components/PageComponents/Connect/Serial.tsx +++ b/src/components/PageComponents/Connect/Serial.tsx @@ -8,12 +8,10 @@ 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 = ({ setConnectionInProgress, closeDialog }: TabElementProps) => { +export const Serial = ({ closeDialog }: TabElementProps) => { const [serialPorts, setSerialPorts] = useState([]); const { addDevice } = useDeviceStore(); - const messageStore = useMessageStore() const { setSelectedDevice } = useAppStore(); const updateSerialPortList = useCallback(async () => { @@ -38,7 +36,7 @@ export const Serial = ({ setConnectionInProgress, closeDialog }: TabElementProps const connection = new MeshDevice(transport, id); connection.configure(); device.addConnection(connection); - subscribeAll(device, connection, messageStore); + subscribeAll(device, connection); closeDialog(); }; @@ -52,9 +50,8 @@ export const Serial = ({ setConnectionInProgress, 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 97eb7542..632d9d29 100644 --- a/src/components/PageComponents/Messages/MessageInput.test.tsx +++ b/src/components/PageComponents/Messages/MessageInput.test.tsx @@ -1,222 +1,152 @@ -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 }) => ( - - )), +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(), })); -vi.mock('@components/UI/Input.tsx', () => ({ - Input: vi.fn(({ autoFocus, minLength, name, placeholder, value, onChange }) => ( - - )), +vi.mock("@core/utils/debounce.ts", () => ({ + debounce: (fn: () => void) => fn, })); -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/Button.tsx", () => ({ + Button: ({ children, ...props }: { children: React.ReactNode }) => })); -vi.mock('lucide-react', () => ({ - SendIcon: vi.fn(() => ), +vi.mock("@components/UI/Input.tsx", () => ({ + Input: (props: any) => })); -describe('MessageInput', () => { - const mockOnSend = vi.fn(); - const defaultProps: MessageInputProps = { - onSend: mockOnSend, - to: 123, - maxBytes: 256, +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, }; + const mockSetMessageDraft = vi.fn(); + const mockSetMessageState = vi.fn(); + const mockSendText = vi.fn().mockResolvedValue(123); + beforeEach(() => { vi.clearAllMocks(); - mockGetDraft.mockReturnValue(''); + (useDevice as Mock).mockReturnValue({ + connection: { + sendText: mockSendText, + }, + setMessageState: mockSetMessageState, + messageDraft: "", + setMessageDraft: mockSetMessageDraft, + hardware: { + myNodeNum: 1234567890, + }, + }); }); - const renderComponent = (props: Partial = {}) => { - render(); - }; + it('renders correctly with initial state', () => { + 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(); - }); - 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}`); + expect(screen.getByText('0/100')).toBeInTheDocument(); }); - 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; + it('updates local draft and byte count when typing', () => { + render(); - fireEvent.change(inputElement, { target: { value: testMessage } }); + const inputField = screen.getByPlaceholderText('Enter Message'); + fireEvent.change(inputField, { target: { value: '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); + expect(screen.getByText('5/100')).toBeInTheDocument(); + expect(inputField).toHaveValue('Hello'); + expect(mockSetMessageDraft).toHaveBeenCalledWith('Hello'); }); - 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'; + it.skip('does not allow input exceeding max bytes', () => { + render(); - fireEvent.change(inputElement, { target: { value: initialValue } }); - expect((inputElement as HTMLInputElement).value).toBe(initialValue); - expect(mockSetDraft).toHaveBeenCalledWith(defaultProps.to, initialValue); - mockSetDraft.mockClear(); + const inputField = screen.getByPlaceholderText('Enter Message'); - fireEvent.change(inputElement, { target: { value: excessiveValue } }); + expect(screen.getByText('0/100')).toBeInTheDocument(); - expect((inputElement as HTMLInputElement).value).toBe(initialValue); - expect(screen.getByTestId('byte-counter')).toHaveTextContent(`${smallMaxBytes}/${smallMaxBytes}`); - expect(mockSetDraft).not.toHaveBeenCalled(); - }); + 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') - 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); - }); + 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 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!'; + it.skip('sends message and resets form when submitting', async () => { + try { + render(); - fireEvent.change(inputElement, { target: { value: testMessageWithWhitespace } }); - fireEvent.submit(formElement); + const inputField = screen.getByPlaceholderText('Enter Message'); + const submitButton = screen.getByText('Send'); - await waitFor(() => { - expect(mockOnSend).toHaveBeenCalledTimes(1); - expect(mockOnSend).toHaveBeenCalledWith(expectedTrimmedMessage); - expect(mockClearDraft).toHaveBeenCalledWith(defaultProps.to); - }); - }); + fireEvent.change(inputField, { target: { value: 'Test Message' } }); + fireEvent.click(submitButton); - 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'); + const form = screen.getByRole('form'); + fireEvent.submit(form); - expect((inputElement as HTMLInputElement).value).toBe(''); + expect(mockSendText).toHaveBeenCalledWith('Test message', 'broadcast', true, 0); - fireEvent.submit(formElement); + await waitFor(() => { + expect(mockSetMessageState).toHaveBeenCalledWith( + 'broadcast', + 0, + 'broadcast', + 1234567890, + 123, + 'ack' + ); - await act(async () => { - await new Promise(resolve => setTimeout(resolve, 50)); - }); + }); - expect(mockOnSend).not.toHaveBeenCalled(); - expect(mockClearDraft).not.toHaveBeenCalled(); + expect(inputField).toHaveValue(''); + expect(screen.getByText('0/100')).toBeInTheDocument(); + expect(mockSetMessageDraft).toHaveBeenCalledWith(''); + } catch (e) { + console.error(e); + } }); + it('prevents sending empty messages', () => { + render(); - 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)); - }); + const form = screen.getByPlaceholderText('Enter Message') + fireEvent.submit(form); - expect(mockOnSend).not.toHaveBeenCalled(); - expect(mockClearDraft).not.toHaveBeenCalled(); - - expect((inputElement as HTMLInputElement).value).toBe(whitespaceMessage); + expect(mockSendText).not.toHaveBeenCalled(); }); - 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'; + 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.change(inputElement, { target: { value: newMessage } }); - expect(mockSetDraft).toHaveBeenCalledWith(broadcastDest, newMessage); + render(); - fireEvent.submit(formElement); + const inputField = screen.getByRole('textbox'); - expect(mockOnSend).toHaveBeenCalledWith(newMessage); - expect(mockClearDraft).toHaveBeenCalledWith(broadcastDest); + expect(inputField).toHaveValue('Existing draft'); }); }); \ No newline at end of file diff --git a/src/components/PageComponents/Messages/MessageInput.tsx b/src/components/PageComponents/Messages/MessageInput.tsx index c229d8aa..b885afaf 100644 --- a/src/components/PageComponents/Messages/MessageInput.tsx +++ b/src/components/PageComponents/Messages/MessageInput.tsx @@ -1,57 +1,101 @@ +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, useState } from "react"; -import { useMessageStore } from "@core/stores/messageStore/index.ts"; +import { startTransition, useCallback, useMemo, useState } from "react"; export interface MessageInputProps { - onSend: (message: string) => void; to: Types.Destination; + channel: Types.ChannelNumber; maxBytes: number; } export const MessageInput = ({ - onSend, to, + channel, maxBytes, }: MessageInputProps) => { - const { setDraft, getDraft, clearDraft } = useMessageStore(); + const { + connection, + setMessageState, + messageDraft, + setMessageDraft, + isQueueingMessages, + queueStatus, + hardware, + } = useDevice(); + const myNodeNum = hardware.myNodeNum; + const [localDraft, setLocalDraft] = useState(messageDraft); + const [messageBytes, setMessageBytes] = useState(0); - const calculateBytes = (text: string) => new Blob([text]).size; + const debouncedSetMessageDraft = useMemo( + () => debounce(setMessageDraft, 300), + [setMessageDraft], + ); + + // sends the message to the selected destination + const sendText = useCallback( + async (message: string) => { - const initialDraft = getDraft(to); - const [localDraft, setLocalDraft] = useState(initialDraft); - const [messageBytes, setMessageBytes] = useState(() => calculateBytes(initialDraft)); + 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 handleInputChange = (e: React.ChangeEvent) => { const newValue = e.target.value; - const byteLength = calculateBytes(newValue); + const byteLength = new Blob([newValue]).size; 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 ff696007..384aaefc 100644 --- a/src/components/PageComponents/Messages/MessageItem.tsx +++ b/src/components/PageComponents/Messages/MessageItem.tsx @@ -5,138 +5,122 @@ import { TooltipProvider, TooltipTrigger, } from "@components/UI/Tooltip.tsx"; -import { useDevice } from "@core/stores/deviceStore.ts"; +import { useDeviceStore } 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 { 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 +import { Message, MessageState } from "@core/services/types.ts"; -interface MessageStatusInfo { +interface MessageProps { + lastMsgSameUser: boolean; + message: Message; +} + +interface MessageStatus { + state: MessageState; displayText: string; icon: LucideIcon; - ariaLabel: string; - iconClassName?: string; } -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 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 UNKNOWN_STATUS: MessageStatusInfo = { displayText: "Unknown state", icon: AlertCircle, ariaLabel: "Message status unknown", iconClassName: "text-red-500 dark:text-red-400" }; +const getMessageStatus = (state: MessageState): MessageStatus => + MESSAGE_STATUS[state] || { state: "failed", displayText: "Unknown error", icon: AlertCircle }; -const getMessageStatusInfo = (state: MessageState): MessageStatusInfo => - MESSAGE_STATUS_MAP[state] ?? UNKNOWN_STATUS; - -const StatusTooltip = ({ statusInfo, children }: { statusInfo: MessageStatusInfo; children: ReactNode }) => ( - +const StatusTooltip = ({ status, children }: { status: MessageStatus; children: ReactNode }) => ( + {children} - - {statusInfo.displayText} + + {status.displayText} ); -interface MessageItemProps { - message: Message; -} - -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]); - - - 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]); - - const messageStatusInfo = getMessageStatusInfo(message.state); - const StatusIconComponent = messageStatusInfo.icon; +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; - const messageDate = useMemo(() => message.date ? new Date(message.date) : null, [message.date]); - const locale = 'en-US'; // TODO: Make dynamic via props or context + return ( + + + + ); +}; - const formattedTime = useMemo(() => - messageDate?.toLocaleTimeString(locale, { hour: 'numeric', minute: '2-digit', hour12: true }) ?? '', - [messageDate, locale]); +const getMessageTextStyles = (status: MessageStatus) => { + const isAcknowledged = status.state === "ack"; + const isFailed = status.state === "failed"; - const fullDateTime = useMemo(() => - messageDate?.toLocaleString(locale, { dateStyle: 'medium', timeStyle: 'short' }) ?? '', - [messageDate, locale]); + 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 isSender = myNodeNum !== undefined && message.from === myNodeNum; - const isOnPrimaryChannel = message.channel === Types.ChannelNumber.Primary; // Use the enum - const shouldShowStatusIcon = isSender && isOnPrimaryChannel; +const TimeDisplay = ({ date, className }: { date: Date; className?: string }) => ( +
    + {date.toLocaleDateString()} + + {date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })} + +
    +); +export const MessageItem = ({ lastMsgSameUser, message }: MessageProps) => { + const { getDevices } = useDeviceStore(); - 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 isDeviceUser = useMemo( + () => + getDevices() + .map((device) => device.nodes.get(device.hardware.myNodeNum)?.num) + .includes(message.from), + [getDevices, message.from], ); - const dateTextStyle = "text-xs text-slate-500 dark:text-slate-400"; + const messageUser = message?.from + ? getDevices().find((device) => device.nodes.has(message.from))?.nodes.get(message.from) + : null; - return ( -
  • -
    - + const messageStatus = getMessageStatus(message.state); + const messageTextClass = getMessageTextStyles(messageStatus); -
    -
    - - {displayName} - - {messageDate && ( - - )} - {shouldShowStatusIcon && ( - - -
  • +
    ); -}; \ 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 39443acb..977ef315 100644 --- a/src/components/PageComponents/Messages/TraceRoute.test.tsx +++ b/src/components/PageComponents/Messages/TraceRoute.test.tsx @@ -1,33 +1,30 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, Mock } 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" } } as Protobuf.Mesh.NodeInfo, + { num: 1, user: { longName: "Node A" } }, ], [ 2, - { num: 2, user: { longName: "Node B" } } as Protobuf.Mesh.NodeInfo, + { num: 2, user: { longName: "Node B" } }, ], [ 3, - { num: 3, user: { longName: "Node C" } } as Protobuf.Mesh.NodeInfo, + { num: 3, user: { longName: "Node C" } }, ], ]); beforeEach(() => { vi.resetAllMocks(); - vi.mocked(useDevice).mockReturnValue({ - getNode: (nodeNum: number): Protobuf.Mesh.NodeInfo | undefined => { - return mockNodes.get(nodeNum); - }, + (useDevice as Mock).mockReturnValue({ + nodes: mockNodes, }); }); @@ -41,16 +38,17 @@ describe("TraceRoute", () => { /> ); - expect(screen.getAllByText("Source Node")).toHaveLength(1); + expect(screen.getByText("Route to destination:")).toBeInTheDocument(); expect(screen.getByText("Destination Node")).toBeInTheDocument(); expect(screen.getByText("Node A")).toBeInTheDocument(); expect(screen.getByText("Node B")).toBeInTheDocument(); - expect(screen.getAllByText(/↓/)).toHaveLength(3); + expect(screen.getAllByText(/↓/)).toHaveLength(3); // startNode + 2 hops 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", () => { @@ -66,20 +64,9 @@ 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", () => { @@ -91,22 +78,18 @@ describe("TraceRoute", () => { /> ); - expect(screen.getByText("Node A")).toBeInTheDocument(); - expect(screen.getAllByText("↓ ??dB")).toHaveLength(2); + expect(screen.getAllByText("↓ ??dB").length).toBeGreaterThan(0); }); it("renders hop hex if node is not found", () => { render( ); - expect(screen.getByText(/^!63$/)).toBeInTheDocument(); - expect(screen.getByText("↓ 5dB")).toBeInTheDocument(); - expect(screen.getByText("↓ 15dB")).toBeInTheDocument(); + expect(screen.getByText(/^!63$/)).toBeInTheDocument(); // 99 in hex }); -}); \ No newline at end of file +}); diff --git a/src/components/PageComponents/Messages/TraceRoute.tsx b/src/components/PageComponents/Messages/TraceRoute.tsx index e67baacc..13f8f08f 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 { getNode } = useDevice(); + const { nodes } = useDevice(); return ( - +

    {title}

    {startNode?.user?.longName}

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

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

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

    + +

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

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

    ))} diff --git a/src/components/PageLayout.tsx b/src/components/PageLayout.tsx index 40ae524e..132e7bc4 100644 --- a/src/components/PageLayout.tsx +++ b/src/components/PageLayout.tsx @@ -1,126 +1,77 @@ -import React from 'react'; import { cn } from "@core/utils/cn.ts"; -import { type LucideIcon } from "lucide-react"; +import { AlignLeftIcon, 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; - actions?: ActionItem[]; - children: React.ReactNode; - leftBar?: React.ReactNode; - rightBar?: React.ReactNode; noPadding?: boolean; - leftBarClassName?: string; - rightBarClassName?: string; - topBarClassName?: string; - contentClassName?: string; + children: React.ReactNode; + className?: string; + actions?: { + icon: LucideIcon; + iconClasses?: string; + onClick: () => void; + disabled?: boolean; + isLoading?: boolean; + }[]; } export const PageLayout = ({ label, + noPadding, actions, + className, children, - leftBar, - rightBar, - noPadding, - leftBarClassName, - rightBarClassName, - topBarClassName, - contentClassName }: PageLayoutProps) => { return ( -
    - {/* Left Sidebar */} - {leftBar && ( - - )} - -
    - {/* Header */} -
    +
    + +
    +
    + {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 caa541f4..2ce7bd9c 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,241 +1,148 @@ -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 { - CircleChevronLeft, + BatteryMediumIcon, CpuIcon, + EditIcon, LayersIcon, type LucideIcon, MapIcon, MessageSquareIcon, - PenLine, - SearchIcon, SettingsIcon, + SidebarCloseIcon, + SidebarOpenIcon, UsersIcon, ZapIcon, } from "lucide-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"; +import { useState } from "react"; 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, getNode, getNodesLength, metadata, activePage, setActivePage, setDialogOpen } = useDevice(); - const { setCommandPaletteOpen } = useAppStore(); - const myNode = getNode(hardware.myNodeNum); - const { isCollapsed } = useSidebar(); + const { hardware, nodes, metadata } = useDevice(); + const myNode = nodes.get(hardware.myNodeNum); 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: `Nodes (${Math.max(getNodesLength() - 1, 0)})`, + 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})`, icon: UsersIcon, page: "nodes", }, ]; - return ( -
    - - -
    - Meshtastic Logo -

    - Meshtastic -

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

    - {myNode.user?.longName} -

    -
    - -
    -
    - -
    -
    - - {myNode.deviceMetrics?.voltage?.toPrecision(3) ?? "UNK"} volts +
    +
    + + {myNode.user?.shortName ?? "UNK"} + + {myNode.user?.longName ?? "UNK"}
    -
    - - 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 979c974b..ee075793 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 }; \ No newline at end of file +export { Button, buttonVariants }; diff --git a/src/components/UI/Checkbox/index.tsx b/src/components/UI/Checkbox/index.tsx index ac6ff441..59240c79 100644 --- a/src/components/UI/Checkbox/index.tsx +++ b/src/components/UI/Checkbox/index.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useId } from "react"; +import { useState, useId } from "react"; import { Check } from "lucide-react"; import { Label } from "@components/UI/Label.tsx"; import { cn } from "@core/utils/cn.ts"; @@ -32,11 +32,6 @@ 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; @@ -71,7 +66,7 @@ export function Checkbox({ > {isChecked && (
    - +
    )}
    diff --git a/src/components/UI/Command.tsx b/src/components/UI/Command.tsx index d6673525..d230d326 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 256e747b..442af95e 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) => ( )); @@ -119,7 +118,7 @@ const DialogDescription = React.forwardRef< >(({ className, ...props }, ref) => ( )); diff --git a/src/components/UI/Footer.tsx b/src/components/UI/Footer.tsx index 9eb56dec..775b68d7 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 (

    diff --git a/src/components/UI/Generator.tsx b/src/components/UI/Generator.tsx index 553748f3..7306b076 100644 --- a/src/components/UI/Generator.tsx +++ b/src/components/UI/Generator.tsx @@ -9,13 +9,7 @@ import { SelectTrigger, SelectValue, } from "@components/UI/Select.tsx"; - -export interface ActionButton { - text: string; - onClick: React.MouseEventHandler; - variant: ButtonVariant; - className?: string; -}[] +import type { LucideIcon } from "lucide-react"; export interface GeneratorProps extends React.BaseHTMLAttributes { type: "text" | "password"; @@ -23,12 +17,19 @@ export interface GeneratorProps extends React.BaseHTMLAttributes { value: string; id: string; variant: "default" | "invalid"; - actionButtons: ActionButton[]; + actionButtons: { + text: string; + onClick: React.MouseEventHandler; + variant: ButtonVariant; + className?: string; + }[]; bits?: { text: string; value: string; key: string }[]; selectChange: (event: string) => void; - inputChange: (event: React.ChangeEventHandler | undefined) => void; - showPasswordToggle?: boolean; - showCopyButton?: boolean; + inputChange: (event: React.ChangeEvent) => void; + action?: { + icon: LucideIcon; + onClick: () => void; + }; disabled?: boolean; } @@ -49,9 +50,8 @@ const Generator = ], selectChange, inputChange, + action, disabled, - showPasswordToggle, - showCopyButton, ...props }: GeneratorProps ) => { @@ -78,28 +78,27 @@ const Generator = variant={variant} value={value} onChange={inputChange} + action={action} disabled={disabled} ref={inputRef} - showCopyButton={showCopyButton} - showPasswordToggle={showPasswordToggle} /> -

    +
    {actionButtons?.map(({ text, onClick, variant, className }) => ( - ))} -
    - )} -
    + {suffix && ( +
    + {suffix} +
    + )} + {action && ( + + )}
    ); - } + }, ); Input.displayName = "Input"; -export { Input, inputVariants }; \ No newline at end of file +export { Input, inputVariants }; diff --git a/src/components/UI/Sidebar/SidebarButton.tsx b/src/components/UI/Sidebar/SidebarButton.tsx deleted file mode 100644 index 9367f654..00000000 --- a/src/components/UI/Sidebar/SidebarButton.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import React from "react"; -import { Button } from "@components/UI/Button.tsx"; -import type { LucideIcon } from "lucide-react"; -import { cn } from "@core/utils/cn.ts"; -import { useSidebar } from "@core/stores/sidebarStore.tsx"; - -export interface SidebarButtonProps { - label: string; - count?: number; - active?: boolean; - Icon?: LucideIcon; - children?: React.ReactNode; - onClick?: () => void; - disabled?: boolean; - preventCollapse?: boolean; -} - -export const SidebarButton = ({ - label, - active, - Icon, - count, - children, - onClick, - disabled = false, - preventCollapse = false, -}: SidebarButtonProps) => { - const { isCollapsed: isSidebarCollapsed } = useSidebar(); - const isButtonCollapsed = isSidebarCollapsed && !preventCollapse; - - return ( - - ); -}; \ No newline at end of file diff --git a/src/components/UI/Sidebar/SidebarSection.tsx b/src/components/UI/Sidebar/SidebarSection.tsx index 1453ee2c..7eefc0a0 100644 --- a/src/components/UI/Sidebar/SidebarSection.tsx +++ b/src/components/UI/Sidebar/SidebarSection.tsx @@ -1,42 +1,19 @@ -import React from "react"; -import { cn } from "@core/utils/cn.ts"; -import { Heading } from "@components/UI/Typography/Heading.tsx"; -import { useSidebar } from "@core/stores/sidebarStore.tsx"; +import { Heading } from "../Typography/Heading.tsx"; -interface SidebarSectionProps { +export interface SidebarSectionProps { label: string; + subheader?: string; children: React.ReactNode; - className?: string; } export const SidebarSection = ({ - label, + label: title, children, - className, -}: SidebarSectionProps) => { - const { isCollapsed } = useSidebar(); - return ( -
    - - - {label} - - -
    - {children} -
    -
    - ); -}; \ No newline at end of file +}: SidebarSectionProps) => ( +
    + + {title} + +
    {children}
    +
    +); diff --git a/src/components/UI/Sidebar/sidebarButton.tsx b/src/components/UI/Sidebar/sidebarButton.tsx index 9367f654..fa569968 100644 --- a/src/components/UI/Sidebar/sidebarButton.tsx +++ b/src/components/UI/Sidebar/sidebarButton.tsx @@ -1,81 +1,32 @@ -import React from "react"; import { Button } from "@components/UI/Button.tsx"; import type { LucideIcon } from "lucide-react"; -import { cn } from "@core/utils/cn.ts"; -import { useSidebar } from "@core/stores/sidebarStore.tsx"; export interface SidebarButtonProps { label: string; - count?: number; active?: boolean; Icon?: LucideIcon; - children?: React.ReactNode; + element?; onClick?: () => void; disabled?: boolean; - preventCollapse?: boolean; } export const SidebarButton = ({ label, active, Icon, - count, - children, + element, onClick, disabled = false, - preventCollapse = false, -}: SidebarButtonProps) => { - const { isCollapsed: isSidebarCollapsed } = useSidebar(); - const isButtonCollapsed = isSidebarCollapsed && !preventCollapse; - - return ( - - ); -}; \ No newline at end of file +}: SidebarButtonProps) => ( + +); diff --git a/src/components/UI/Slider.tsx b/src/components/UI/Slider.tsx deleted file mode 100644 index 593c1bf0..00000000 --- a/src/components/UI/Slider.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { useId, useState } from "react"; -import * as SliderPrimitive from "@radix-ui/react-slider"; -import { cn } from "@core/utils/cn.ts"; - -export interface SliderProps { - value: number[]; - step?: number; - min?: number; - max: number; - onValueChange?: (value: number[]) => void; - onValueCommit?: (value: number[]) => void; - disabled?: boolean; - className?: string; - trackClassName?: string; - rangeClassName?: string; - thumbClassName?: string; -} - -export function Slider({ - value, - step = 1, - min = 0, - max, - onValueChange, - onValueCommit, - disabled = false, - className, - trackClassName, - rangeClassName, - thumbClassName, - ...props -}: SliderProps) { - const [internalValue, setInternalValue] = useState(value); - const isControlled = value !== undefined; - const currentValue = isControlled ? value! : internalValue; - const id = useId(); - - const handleValueChange = (newValue: number[]) => { - if (!isControlled) setInternalValue(newValue); - onValueChange?.(newValue); - }; - - const handleValueCommit = (newValue: number[]) => { - onValueCommit?.(newValue); - }; - - return ( - - - - - {currentValue.map((_, i) => ( - - ))} - - ); -} diff --git a/src/components/UI/Tabs.tsx b/src/components/UI/Tabs.tsx index 88b048b9..248d2797 100644 --- a/src/components/UI/Tabs.tsx +++ b/src/components/UI/Tabs.tsx @@ -12,7 +12,7 @@ const TabsList = React.forwardRef< ( target="_blank" rel="noopener noreferrer" className={cn( - "font-medium text-slate-900 underline underline-offset-4 dark:text-slate-200", + "font-medium text-slate-900 underline underline-offset-4 dark:text-slate-900", className, )} > diff --git a/src/components/generic/Mono.tsx b/src/components/generic/Mono.tsx index c3d0d61d..af565a33 100644 --- a/src/components/generic/Mono.tsx +++ b/src/components/generic/Mono.tsx @@ -1,17 +1,11 @@ -import { cn } from "@core/utils/cn.ts"; - -interface MonoProps extends React.HTMLAttributes { - children: React.ReactNode; - className?: string; -} export const Mono = ({ children, className, ...rest -}: MonoProps) => { +}: JSX.IntrinsicElements["span"]) => { return ( {children} diff --git a/src/components/generic/Table/index.tsx b/src/components/generic/Table/index.tsx index 5da01742..1603fd00 100755 --- a/src/components/generic/Table/index.tsx +++ b/src/components/generic/Table/index.tsx @@ -17,11 +17,11 @@ export interface Heading { * @returns number of hopsAway or `0` if hopsAway is 'Direct' */ function numericHops(hopsAway: string): number { - if (hopsAway.match(/direct/i)) { + if(hopsAway.match(/direct/i)){ return 0; } - if (hopsAway.match(/\d+\s+hop/gi)) { - return Number(hopsAway.match(/(\d+)\s+hop/i)?.[1]); + if ( hopsAway.match(/\d+\s+hop/gi) ) { + return Number( hopsAway.match(/(\d+)\s+hop/i)?.[1] ); } return Number.MAX_SAFE_INTEGER; } @@ -46,6 +46,7 @@ export const Table = ({ headings, rows }: TableProps) => { const aValue = a[columnIndex].props.children; const bValue = b[columnIndex].props.children; + // Custom comparison for 'Last Heard' column if (sortColumn === "Last Heard") { const aTimestamp = aValue.props.timestamp ?? 0; const bTimestamp = bValue.props.timestamp ?? 0; @@ -59,10 +60,11 @@ export const Table = ({ headings, rows }: TableProps) => { return 0; } + // Custom comparison for 'Connection' column if (sortColumn === "Connection") { const aNumHops = numericHops(aValue instanceof Array ? aValue[0] : aValue); const bNumHops = numericHops(bValue instanceof Array ? bValue[0] : bValue); - + if (aNumHops < bNumHops) { return sortOrder === "asc" ? -1 : 1; } @@ -72,6 +74,7 @@ export const Table = ({ headings, rows }: TableProps) => { return 0; } + // Default comparison for other columns if (aValue < bValue) { return sortOrder === "asc" ? -1 : 1; } @@ -83,16 +86,17 @@ export const Table = ({ headings, rows }: TableProps) => { return ( - + {headings.map((heading) => ( - - {sortedRows.map((row, index) => { + + {sortedRows.map((row, index) => ( // biome-ignore lint/suspicious/noArrayIndexKey: TODO: Once this table is sortable, this should get fixed. - return ( - {row.map((item, index) => { - return (index === 0 ? - : - ) - })} + + {row.map((item, index) => ( + index === 0 ? + : + + ))} - ); - })} + ))}
    heading.sortable && headingSort(heading.title)} onKeyUp={() => heading.sortable && headingSort(heading.title)} > @@ -107,29 +111,28 @@ export const Table = ({ headings, rows }: TableProps) => { ))}
    - {item} - - {item} -
    + {item} + + {item} +
    ); diff --git a/src/core/dto/NodeNumToNodeInfoDTO.ts b/src/core/dto/NodeNumToNodeInfoDTO.ts deleted file mode 100644 index 60222502..00000000 --- a/src/core/dto/NodeNumToNodeInfoDTO.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { create } from "@bufbuild/protobuf"; -import { Protobuf } from "@meshtastic/core"; - -class NodeInfoFactory { - private static createDefaultUser(num: number): Protobuf.Mesh.User { - const userIdHex = num.toString(16).toUpperCase().padStart(2, '0'); - const userId = `!${userIdHex}`; - const last4 = userIdHex.slice(-4); - const longName = `Meshtastic ${last4}`; - const shortName = last4; - const hwModel = Protobuf.Mesh.HardwareModel.UNSET; - - return create(Protobuf.Mesh.UserSchema, { - id: userId, - longName: longName, - shortName: shortName, - hwModel: hwModel, - isLicensed: false, - }); - } - - public static ensureDefaultUser(node: Protobuf.Mesh.NodeInfo): Protobuf.Mesh.NodeInfo { - if (!node) { - return node; - } - - if (!node.user) { - if (node.num === undefined || node.num === null) { - console.error(`NodeInfoFactory.ensureDefaultUser: Cannot create default user for node because 'num' is missing.`, node); - return node; - } - - node.user = NodeInfoFactory.createDefaultUser(node.num); - } - - return node; - } -} - -export default NodeInfoFactory; \ No newline at end of file diff --git a/src/core/dto/PacketToMessageDTO.ts b/src/core/dto/PacketToMessageDTO.ts deleted file mode 100644 index d9309166..00000000 --- a/src/core/dto/PacketToMessageDTO.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { Types } from "@meshtastic/js"; -import { Message, MessageType, MessageState } from "../stores/messageStore/index.ts"; - -class PacketToMessageDTO { - channel: Types.ChannelNumber; - to: number; - from: number; - date: number; // (timestamp ms) - messageId: number; - state: MessageState; - message: string; - type: MessageType; - - constructor(data: Types.PacketMetadata, nodeNum: number) { - this.channel = data.channel; - this.to = data.to; - this.from = data.from; - this.messageId = data.id; - this.state = data.from !== nodeNum ? MessageState.Ack : MessageState.Waiting; - this.message = data.data; - this.type = (data.type === 'direct') ? MessageType.Direct : MessageType.Broadcast; - - let dateTimestamp = Date.now(); - if (data.rxTime instanceof Date) { - const timeValue = data.rxTime.getTime(); - - if (!isNaN(timeValue)) { - dateTimestamp = timeValue; - } - } - else if (data.rxTime != null) { - console.warn(`Received rxTime in PacketToMessageDTO was not a Date object as expected (type: ${typeof data.rxTime}, value: ${data.rxTime}). Using current time as fallback.`); - } - this.date = dateTimestamp; - } - - toMessage(): Message { - return { - channel: this.channel, - to: this.to, - from: this.from, - date: this.date, - messageId: this.messageId, - state: this.state, - message: this.message, - type: this.type, - }; - } -} - -export default PacketToMessageDTO; \ No newline at end of file diff --git a/src/core/hooks/useCopyToClipboard.ts b/src/core/hooks/useCopyToClipboard.ts deleted file mode 100644 index 19292bcd..00000000 --- a/src/core/hooks/useCopyToClipboard.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from "react"; - -interface UseCopyToClipboardProps { - timeout?: number; -} - -export function useCopyToClipboard({ timeout = 2000 }: UseCopyToClipboardProps = {}) { - const [isCopied, setIsCopied] = useState(false); - const timeoutRef = useRef(null); - - useEffect(() => { - return () => { - if (timeoutRef.current) { - globalThis.clearTimeout(timeoutRef.current); - } - }; - }, []); - - const copy = useCallback( - async (text: string) => { - if (!navigator?.clipboard) { - console.warn('Clipboard API not available'); - setIsCopied(false); - return false; - } - - if (timeoutRef.current) { - globalThis.clearTimeout(timeoutRef.current); - } - - try { - await navigator.clipboard.writeText(text); - setIsCopied(true); - - timeoutRef.current = globalThis.setTimeout(() => { - setIsCopied(false); - timeoutRef.current = null; - }, timeout); - - return true; - } catch (error) { - console.error('Failed to copy text to clipboard:', error); - setIsCopied(false); - return false; - } - }, - [timeout] - ); - - return { isCopied, copy }; -} \ No newline at end of file diff --git a/src/core/hooks/useNodeFilters.ts b/src/core/hooks/useNodeFilters.ts deleted file mode 100644 index 529a2773..00000000 --- a/src/core/hooks/useNodeFilters.ts +++ /dev/null @@ -1,294 +0,0 @@ -import { useCallback, useMemo, useState } from "react"; -import { Protobuf } from "@meshtastic/core"; -import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; - -interface BooleanFilter { - key: string; - label: string; - group: string; - type: "boolean"; - predicate: (node: Protobuf.Mesh.NodeInfo, value: boolean) => boolean; -} - -interface RangeFilter { - key: string; - label: string; - group: string; - type: "range"; - bounds: [number, number]; - predicate: (node: Protobuf.Mesh.NodeInfo, value: [number, number]) => boolean; -} - -interface SearchFilter { - key: string; - label: string; - group: string; - type: "search"; - predicate: (node: Protobuf.Mesh.NodeInfo, value: string) => boolean; -} - -interface MultiFilter { - key: string; - label: string; - group: string; - type: "multi"; - options: string[]; - predicate: (node: Protobuf.Mesh.NodeInfo, value: string[]) => boolean; -} - -export type FilterConfig = - | BooleanFilter - | RangeFilter - | SearchFilter - | MultiFilter; - -export type FilterValueMap = { - [C in FilterConfig as C["key"]]: C extends BooleanFilter ? boolean - : C extends RangeFilter ? [number, number] - : C extends SearchFilter ? string - : C extends MultiFilter ? string[] - : never; -}; - -// Defines all node filters in this object -export const filterConfigs: FilterConfig[] = [ - { - key: "searchText", - label: "Node name/number", - group: "General", - type: "search", - predicate: (node, text: string) => { - if (!text) return true; - const shortName = node.user?.shortName?.toString().toLowerCase() ?? ""; - const longName = node.user?.longName?.toString().toLowerCase() ?? ""; - const nodeNum = node.num?.toString() ?? ""; - const nodeNumHex = numberToHexUnpadded(node.num) ?? ""; - const search = text.toLowerCase(); - return shortName.includes(search) || longName.includes(search) || - nodeNum.includes(search) || - nodeNumHex.includes(search.replace(/!/g, "")); - }, - }, - { - key: "hopRange", - label: "Number of hops", - group: "General", - type: "range", - bounds: [0, 7], - predicate: (node, [min, max]: [number, number]) => { - const hops = node.hopsAway ?? 7; - return hops >= min && hops <= max; - }, - }, - { - key: "lastHeard", - label: "Last heard", - group: "General", - type: "range", - bounds: [0, 864000], // 10 days - predicate: (node, [min, max]: [number, number]) => { - const secondsAgo = Date.now() / 1000 - node.lastHeard; - return (secondsAgo >= min && secondsAgo <= max) || - (secondsAgo >= min && max == 864000); - }, - }, - { - key: "favOnly", - label: "Show favourites only", - group: "General", - type: "boolean", - predicate: (node, favOnly: boolean) => !favOnly || node.isFavorite, - }, - { - key: "viaMqtt", - label: "Hide MQTT-connected nodes", - group: "General", - type: "boolean", - predicate: (node, hide: boolean) => !hide || !node.viaMqtt, - }, - { - key: "snr", - label: "SNR (db)", - group: "Metrics", - type: "range", - bounds: [-20, 10], - predicate: (node, [min, max]: [number, number]) => { - const snr = node.snr ?? -20; - return snr >= min && snr <= max; - }, - }, - { - key: "channelUtilization", - label: "Channel Utilization (%)", - group: "Metrics", - type: "range", - bounds: [0, 100], - predicate: (node, [min, max]: [number, number]) => { - const channelUtilization = node.deviceMetrics?.channelUtilization ?? 0; - return channelUtilization >= min && channelUtilization <= max; - }, - }, - { - key: "airUtilTx", - label: "Airtime Utilization (%)", - group: "Metrics", - type: "range", - bounds: [0, 100], - predicate: (node, [min, max]: [number, number]) => { - const airUtilTx = node.deviceMetrics?.airUtilTx ?? 0; - return airUtilTx >= min && airUtilTx <= max; - }, - }, - { - key: "battery", - label: "Battery level (%)", - group: "Metrics", - type: "range", - bounds: [0, 101], - predicate: (node, [min, max]: [number, number]) => { - const batt = node.deviceMetrics?.batteryLevel ?? 101; - return batt >= min && batt <= max; - }, - }, - { - key: "voltage", - label: "Battery voltage (V)", - group: "Metrics", - type: "range", - bounds: [0.1, 5.0], - predicate: (node, [min, max]: [number, number]) => { - const batt = node.deviceMetrics?.voltage ?? 5; - return batt >= min && batt <= max; - }, - }, - { - key: "role", - label: "Role", - group: "Role", - type: "multi", - options: Object.keys(Protobuf.Config.Config_DeviceConfig_Role) - .filter((k) => isNaN(Number(k))) - .map((k) => { - const spaced = k.replace(/_/g, " "); - return spaced.charAt(0).toUpperCase() + spaced.slice(1).toLowerCase(); - }), - predicate: (node, selected) => { - return selected.map((k) => { - const unSpaced = k.replace(/ /g, "_"); - return unSpaced.toUpperCase(); - }).includes( - Protobuf.Config.Config_DeviceConfig_Role[node.user?.role ?? 0], - ); - }, - }, - { - key: "hwModel", - label: "Hardware model", - group: "Hardware", - type: "multi", - options: Object.keys(Protobuf.Mesh.HardwareModel) - .filter((k) => isNaN(Number(k))) - .map((k) => { - return k.replace(/_/g, " "); - }), - predicate: (node, selected) => { - return selected.map((k) => { - const unSpaced = k.replace(/ /g, "_"); - return unSpaced.toUpperCase(); - }).includes(Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]); - }, - }, -]; - -export function useNodeFilters(nodes: Protobuf.Mesh.NodeInfo[]) { - const defaultState = useMemo(() => { - return filterConfigs.reduce((acc, cfg) => { - switch (cfg.type) { - case "boolean": - acc[cfg.key] = false; - break; - case "range": - acc[cfg.key] = cfg.bounds; - break; - case "search": - acc[cfg.key] = ""; - break; - case "multi": - acc[cfg.key] = cfg.options; - break; - } - return acc; - }, {} as FilterValueMap); - }, []); - - const [filters, setFilters] = useState( - defaultState, - ); - - const groupedFilterConfigs = useMemo(() => { - return filterConfigs.reduce>((acc, cfg) => { - const g = "group" in cfg ? cfg.group : "General"; - if (!acc[g]) acc[g] = []; - acc[g].push(cfg); - return acc; - }, {}); - }, [filterConfigs]); - - const resetFilters = useCallback(() => { - setFilters(defaultState); - }, [defaultState]); - - const onFilterChange = useCallback( - (key: K, value: FilterValueMap[K]) => { - setFilters((f) => ({ ...f, [key]: value })); - }, - [], - ); - - const filteredNodes = useMemo( - () => - nodes.filter((node) => - filterConfigs.every((cfg) => { - const val = filters[cfg.key]; - switch (cfg.type) { - case "boolean": - if (typeof val !== "boolean") return true; - return cfg.predicate(node, val); - - case "range": { - if ( - !Array.isArray(val) || - val.length !== 2 || - typeof val[0] !== "number" || - typeof val[1] !== "number" - ) { - return true; - } - const tuple: [number, number] = [val[0], val[1]]; - return cfg.predicate(node, tuple); - } - case "multi": { - const safeArray = (() => { - if (!Array.isArray(val)) return []; - return val.filter((x): x is string => typeof x === "string"); - })(); - return cfg.predicate(node, safeArray); - } - case "search": - if (typeof val !== "string") return true; - return cfg.predicate(node, val); - } - }) - ), - [nodes, filters], - ); - - return { - filters, - defaultState, - onFilterChange, - resetFilters, - filteredNodes, - groupedFilterConfigs, - }; -} diff --git a/src/core/hooks/usePasswordVisibilityToggle.test.ts b/src/core/hooks/usePasswordVisibilityToggle.test.ts deleted file mode 100644 index e48e5e17..00000000 --- a/src/core/hooks/usePasswordVisibilityToggle.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { renderHook, act } from '@testing-library/react'; -import { describe, it, expect } from 'vitest'; -import { usePasswordVisibilityToggle } from './usePasswordVisibilityToggle.ts'; - -describe('usePasswordVisibilityToggle Hook', () => { - it('should initialize with visibility set to false by default', () => { - const { result } = renderHook(() => usePasswordVisibilityToggle()); - expect(result.current.isVisible).toBe(false); - expect(typeof result.current.toggleVisibility).toBe('function'); - }); - - it('should initialize with visibility set to true if initialVisible is true', () => { - const { result } = renderHook(() => - usePasswordVisibilityToggle({ initialVisible: true }) - ); - expect(result.current.isVisible).toBe(true); - }); - - it('should toggle visibility from false to true when toggleVisibility is called', () => { - const { result } = renderHook(() => usePasswordVisibilityToggle()); - expect(result.current.isVisible).toBe(false); - act(() => { - result.current.toggleVisibility(); - }); - expect(result.current.isVisible).toBe(true); - }); - - it('should toggle visibility from true to false when toggleVisibility is called', () => { - const { result } = renderHook(() => - usePasswordVisibilityToggle({ initialVisible: true }) - ); - expect(result.current.isVisible).toBe(true); - act(() => { - result.current.toggleVisibility(); - }); - expect(result.current.isVisible).toBe(false); - }); - - it('should toggle visibility correctly multiple times', () => { - const { result } = renderHook(() => usePasswordVisibilityToggle()); - expect(result.current.isVisible).toBe(false); - act(() => { - result.current.toggleVisibility(); - }); - expect(result.current.isVisible).toBe(true); - act(() => { - result.current.toggleVisibility(); - }); - expect(result.current.isVisible).toBe(false); - act(() => { - result.current.toggleVisibility(); - }); - expect(result.current.isVisible).toBe(true); - }); - - it('should return a stable toggleVisibility function reference (due to useCallback)', () => { - const { result, rerender } = renderHook(() => usePasswordVisibilityToggle()); - const initialToggleFunc = result.current.toggleVisibility; - rerender(); - expect(result.current.toggleVisibility).toBe(initialToggleFunc); - act(() => { - result.current.toggleVisibility(); - }); - expect(result.current.isVisible).toBe(true); - }); -}); \ No newline at end of file diff --git a/src/core/hooks/usePasswordVisibilityToggle.ts b/src/core/hooks/usePasswordVisibilityToggle.ts deleted file mode 100644 index 2d02403e..00000000 --- a/src/core/hooks/usePasswordVisibilityToggle.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { useState, useCallback } from 'react'; - -interface UsePasswordVisibilityToggleProps { - initialVisible?: boolean; -} -/** - * Manages the state for toggling password visibility. - * - * @param {boolean} [options.initialVisible=false] - * @returns {{isVisible: boolean, toggleVisibility: () => void}} - */ -export function usePasswordVisibilityToggle({ initialVisible = false }: UsePasswordVisibilityToggleProps = {}) { - const [isVisible, setIsVisible] = useState(initialVisible); - - const toggleVisibility = useCallback(() => { - setIsVisible(prev => !prev); - }, []); - - return { isVisible, toggleVisibility }; -} \ No newline at end of file diff --git a/src/core/stores/appStore.ts b/src/core/stores/appStore.ts index d7cc67fb..4b286e6f 100644 --- a/src/core/stores/appStore.ts +++ b/src/core/stores/appStore.ts @@ -1,3 +1,4 @@ +import { Types } from "@meshtastic/core"; import { produce } from "immer"; import { create } from "zustand"; @@ -24,11 +25,14 @@ interface AppState { id: number; num: number; }[]; + rasterSources: RasterSource[]; commandPaletteOpen: boolean; nodeNumToBeRemoved: number; connectDialogOpen: boolean; nodeNumDetails: number; + activeChat: number; + chatType: "broadcast" | "direct"; errors: ErrorState[]; setRasterSources: (sources: RasterSource[]) => void; @@ -41,6 +45,8 @@ interface AppState { setNodeNumToBeRemoved: (nodeNum: number) => void; setConnectDialogOpen: (open: boolean) => void; setNodeNumDetails: (nodeNum: number) => void; + setActiveChat: (chat: number) => void; + setChatType: (type: "broadcast" | "direct") => void; // Error management hasErrors: () => boolean; @@ -61,6 +67,8 @@ export const useAppStore = create()((set, get) => ({ connectDialogOpen: false, nodeNumToBeRemoved: 0, nodeNumDetails: 0, + activeChat: Types.ChannelNumber.Primary, + chatType: "broadcast", errors: [], setRasterSources: (sources: RasterSource[]) => { @@ -119,6 +127,14 @@ export const useAppStore = create()((set, get) => ({ set(() => ({ nodeNumDetails: nodeNum, })), + setActiveChat: (chat) => + set(() => ({ + activeChat: chat, + })), + setChatType: (type) => + set(() => ({ + chatType: type, + })), hasErrors: () => { const state = get(); return state.errors.length > 0; diff --git a/src/core/stores/deviceStore.ts b/src/core/stores/deviceStore.ts index 0bca0653..c6e74551 100644 --- a/src/core/stores/deviceStore.ts +++ b/src/core/stores/deviceStore.ts @@ -6,13 +6,34 @@ import { create as createStore } from "zustand"; export type Page = "messages" | "map" | "config" | "channels" | "nodes"; +export interface MessageWithState extends Types.PacketMetadata { + state: MessageState; +} + +export type MessageState = "ack" | "waiting" | Protobuf.Mesh.Routing_Error; + export interface ProcessPacketParams { from: number; snr: number; time: number; } -export type DialogVariant = keyof Device["dialog"]; +export type DialogVariant = + | "import" + | "QR" + | "shutdown" + | "reboot" + | "rebootOTA" + | "deviceName" + | "nodeRemoval" + | "pkiBackup" + | "nodeDetails" + | "unsafeRoles" + | "refreshKeys"; + +type QueueStatus = { + res: number, free: number, maxlen: number +} type NodeError = { node: number; @@ -28,7 +49,12 @@ export interface Device { workingConfig: Protobuf.Config.Config[]; workingModuleConfig: Protobuf.ModuleConfig.ModuleConfig[]; hardware: Protobuf.Mesh.MyNodeInfo; + nodes: Map; metadata: Map; + messages: { + direct: Map; + broadcast: Map; + }; traceroutes: Map< number, Types.PacketMetadata[] @@ -38,10 +64,11 @@ export interface Device { activePage: Page; activeNode: number; waypoints: Protobuf.Mesh.Waypoint[]; + // currentMetrics: Protobuf.DeviceMetrics; pendingSettingsChanges: boolean; messageDraft: string; - unreadCounts: Map; - nodesMap: Map; // dont access directly, use getNodes, or getNode + queueStatus: QueueStatus, + isQueueingMessages: boolean, dialog: { import: boolean; QR: boolean; @@ -54,15 +81,16 @@ export interface Device { nodeDetails: boolean; unsafeRoles: boolean; refreshKeys: boolean; - deleteMessages: boolean; }; + setStatus: (status: Types.DeviceStatusEnum) => void; setConfig: (config: Protobuf.Config.Config) => void; setModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => void; setWorkingConfig: (config: Protobuf.Config.Config) => void; setWorkingModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => void; setHardware: (hardware: Protobuf.Mesh.MyNodeInfo) => void; + // setMetrics: (metrics: Types.PacketMetadata) => void; setActivePage: (page: Page) => void; setActiveNode: (node: number) => void; setPendingSettingsChanges: (state: boolean) => void; @@ -72,27 +100,29 @@ export interface Device { addUser: (user: Types.PacketMetadata) => void; addPosition: (position: Types.PacketMetadata) => void; addConnection: (connection: MeshDevice) => void; + addMessage: (message: MessageWithState) => void; addTraceRoute: ( traceroute: Types.PacketMetadata, ) => void; addMetadata: (from: number, metadata: Protobuf.Mesh.DeviceMetadata) => void; removeNode: (nodeNum: number) => void; + setMessageState: ( + type: "direct" | "broadcast", + channelIndex: Types.ChannelNumber, + to: number, + from: number, + messageId: number, + state: MessageState, + ) => void; setDialogOpen: (dialog: DialogVariant, open: boolean) => void; getDialogOpen: (dialog: DialogVariant) => boolean; processPacket: (data: ProcessPacketParams) => void; setMessageDraft: (message: string) => void; + setQueueStatus: (status: QueueStatus) => void; setNodeError: (nodeNum: number, error: string) => void; clearNodeError: (nodeNum: number) => void; getNodeError: (nodeNum: number) => NodeError | undefined; - hasNodeError: (nodeNum: number) => boolean; - incrementUnread: (nodeNum: number) => void; - resetUnread: (nodeNum: number) => void; - getNodes: ( - filter?: (node: Protobuf.Mesh.NodeInfo) => boolean, - ) => Protobuf.Mesh.NodeInfo[]; - getNodesLength: () => number; - getNode: (nodeNum: number) => Protobuf.Mesh.NodeInfo | undefined; - + hasNodeError: (nodeNum: number) => boolean } export interface DeviceState { @@ -110,7 +140,6 @@ export const useDeviceStore = createStore((set, get) => ({ remoteDevices: new Map(), addDevice: (id: number) => { - set( produce((draft) => { draft.devices.set(id, { @@ -122,12 +151,21 @@ export const useDeviceStore = createStore((set, get) => ({ workingConfig: [], workingModuleConfig: [], hardware: create(Protobuf.Mesh.MyNodeInfoSchema), + nodes: new Map(), metadata: new Map(), + messages: { + direct: new Map(), + broadcast: new Map(), + }, traceroutes: new Map(), connection: undefined, activePage: "messages", activeNode: 0, waypoints: [], + queueStatus: { + res: 0, free: 0, maxlen: 0 + }, + isQueueingMessages: false, dialog: { import: false, QR: false, @@ -140,13 +178,11 @@ export const useDeviceStore = createStore((set, get) => ({ unsafeRoles: false, refreshKeys: false, rebootOTA: false, - deleteMessages: false, }, pendingSettingsChanges: false, messageDraft: "", nodeErrors: new Map(), - unreadCounts: new Map(), - nodesMap: new Map(), + setStatus: (status: Types.DeviceStatusEnum) => { set( @@ -162,16 +198,40 @@ export const useDeviceStore = createStore((set, get) => ({ set( produce((draft) => { const device = draft.devices.get(id); + if (device) { switch (config.payloadVariant.case) { - case "device": { device.config.device = config.payloadVariant.value; break; } - case "position": { device.config.position = config.payloadVariant.value; break; } - case "power": { device.config.power = config.payloadVariant.value; break; } - case "network": { device.config.network = config.payloadVariant.value; break; } - case "display": { device.config.display = config.payloadVariant.value; break; } - case "lora": { device.config.lora = config.payloadVariant.value; break; } - case "bluetooth": { device.config.bluetooth = config.payloadVariant.value; break; } - case "security": { device.config.security = config.payloadVariant.value; } + case "device": { + device.config.device = config.payloadVariant.value; + break; + } + case "position": { + device.config.position = config.payloadVariant.value; + break; + } + case "power": { + device.config.power = config.payloadVariant.value; + break; + } + case "network": { + device.config.network = config.payloadVariant.value; + break; + } + case "display": { + device.config.display = config.payloadVariant.value; + break; + } + case "lora": { + device.config.lora = config.payloadVariant.value; + break; + } + case "bluetooth": { + device.config.bluetooth = config.payloadVariant.value; + break; + } + case "security": { + device.config.security = config.payloadVariant.value; + } } } }), @@ -181,20 +241,66 @@ export const useDeviceStore = createStore((set, get) => ({ set( produce((draft) => { const device = draft.devices.get(id); + if (device) { switch (config.payloadVariant.case) { - case "mqtt": { device.moduleConfig.mqtt = config.payloadVariant.value; break; } - case "serial": { device.moduleConfig.serial = config.payloadVariant.value; break; } - case "externalNotification": { device.moduleConfig.externalNotification = config.payloadVariant.value; break; } - case "storeForward": { device.moduleConfig.storeForward = config.payloadVariant.value; break; } - case "rangeTest": { device.moduleConfig.rangeTest = config.payloadVariant.value; break; } - case "telemetry": { device.moduleConfig.telemetry = config.payloadVariant.value; break; } - case "cannedMessage": { device.moduleConfig.cannedMessage = config.payloadVariant.value; break; } - case "audio": { device.moduleConfig.audio = config.payloadVariant.value; break; } - case "neighborInfo": { device.moduleConfig.neighborInfo = config.payloadVariant.value; break; } - case "ambientLighting": { device.moduleConfig.ambientLighting = config.payloadVariant.value; break; } - case "detectionSensor": { device.moduleConfig.detectionSensor = config.payloadVariant.value; break; } - case "paxcounter": { device.moduleConfig.paxcounter = config.payloadVariant.value; break; } + case "mqtt": { + device.moduleConfig.mqtt = config.payloadVariant.value; + break; + } + case "serial": { + device.moduleConfig.serial = config.payloadVariant.value; + break; + } + case "externalNotification": { + device.moduleConfig.externalNotification = + config.payloadVariant.value; + break; + } + case "storeForward": { + device.moduleConfig.storeForward = + config.payloadVariant.value; + break; + } + case "rangeTest": { + device.moduleConfig.rangeTest = + config.payloadVariant.value; + break; + } + case "telemetry": { + device.moduleConfig.telemetry = + config.payloadVariant.value; + break; + } + case "cannedMessage": { + device.moduleConfig.cannedMessage = + config.payloadVariant.value; + break; + } + case "audio": { + device.moduleConfig.audio = config.payloadVariant.value; + break; + } + case "neighborInfo": { + device.moduleConfig.neighborInfo = + config.payloadVariant.value; + break; + } + case "ambientLighting": { + device.moduleConfig.ambientLighting = + config.payloadVariant.value; + break; + } + case "detectionSensor": { + device.moduleConfig.detectionSensor = + config.payloadVariant.value; + break; + } + case "paxcounter": { + device.moduleConfig.paxcounter = + config.payloadVariant.value; + break; + } } } }), @@ -204,30 +310,40 @@ export const useDeviceStore = createStore((set, get) => ({ set( produce((draft) => { const device = draft.devices.get(id); - if (!device) return; - const index = device.workingConfig.findIndex( + if (!device) { + return; + } + const workingConfigIndex = device?.workingConfig.findIndex( (wc) => wc.payloadVariant.case === config.payloadVariant.case, ); - if (index !== -1) { - device.workingConfig[index] = config; + if (workingConfigIndex !== -1) { + device.workingConfig[workingConfigIndex] = config; } else { - device.workingConfig.push(config); + device?.workingConfig.push(config); } }), ); }, - setWorkingModuleConfig: (moduleConfig: Protobuf.ModuleConfig.ModuleConfig) => { + setWorkingModuleConfig: ( + moduleConfig: Protobuf.ModuleConfig.ModuleConfig, + ) => { set( produce((draft) => { const device = draft.devices.get(id); - if (!device) return; - const index = device.workingModuleConfig.findIndex( - (wmc) => wmc.payloadVariant.case === moduleConfig.payloadVariant.case, - ); - if (index !== -1) { - device.workingModuleConfig[index] = moduleConfig; + if (!device) { + return; + } + const workingModuleConfigIndex = device?.workingModuleConfig + .findIndex( + (wmc) => + wmc.payloadVariant.case === + moduleConfig.payloadVariant.case, + ); + if (workingModuleConfigIndex !== -1) { + device.workingModuleConfig[workingModuleConfigIndex] = + moduleConfig; } else { - device.workingModuleConfig.push(moduleConfig); + device?.workingModuleConfig.push(moduleConfig); } }), ); @@ -242,6 +358,50 @@ export const useDeviceStore = createStore((set, get) => ({ }), ); }, + // setMetrics: (metrics: Types.PacketMetadata) => { + // set( + // produce((draft) => { + // const device = draft.devices.get(id); + // let node = device?.nodes.find( + // (n) => n.data.num === metrics.from + // ); + // if (node) { + // switch (metrics.data.variant.case) { + // case "deviceMetrics": + // if (device) { + // if (metrics.data.variant.value.batteryLevel) { + // device.currentMetrics.batteryLevel = + // metrics.data.variant.value.batteryLevel; + // } + // if (metrics.data.variant.value.voltage) { + // device.currentMetrics.voltage = + // metrics.data.variant.value.voltage; + // } + // if (metrics.data.variant.value.airUtilTx) { + // device.currentMetrics.airUtilTx = + // metrics.data.variant.value.airUtilTx; + // } + // if (metrics.data.variant.value.channelUtilization) { + // device.currentMetrics.channelUtilization = + // metrics.data.variant.value.channelUtilization; + // } + // } + // node.deviceMetrics.push({ + // metric: metrics.data.variant.value, + // timestamp: metrics.rxTime + // }); + // break; + // case "environmentMetrics": + // node.environmentMetrics.push({ + // metric: metrics.data.variant.value, + // timestamp: metrics.rxTime + // }); + // break; + // } + // } + // }) + // ); + // }, setActivePage: (page) => { set( produce((draft) => { @@ -266,9 +426,10 @@ export const useDeviceStore = createStore((set, get) => ({ set( produce((draft) => { const device = draft.devices.get(id); - if (device) { - device.channels.set(channel.index, channel); + if (!device) { + return; } + device.channels.set(channel.index, channel); }), ); }, @@ -277,9 +438,12 @@ export const useDeviceStore = createStore((set, get) => ({ produce((draft) => { const device = draft.devices.get(id); if (device) { - const index = device.waypoints.findIndex((wp) => wp.id === waypoint.id); - if (index !== -1) { - device.waypoints[index] = waypoint; + const waypointIndex = device.waypoints.findIndex( + (wp) => wp.id === waypoint.id, + ); + + if (waypointIndex !== -1) { + device.waypoints[waypointIndex] = waypoint; } else { device.waypoints.push(waypoint); } @@ -288,14 +452,13 @@ export const useDeviceStore = createStore((set, get) => ({ ); }, addNodeInfo: (nodeInfo) => { - console.log("Node Info", nodeInfo); - set( produce((draft) => { const device = draft.devices.get(id); - - if (!device) return; - device.nodesMap.set(nodeInfo.num, nodeInfo); + if (!device) { + return; + } + device.nodes.set(nodeInfo.num, nodeInfo); }), ); }, @@ -316,11 +479,11 @@ export const useDeviceStore = createStore((set, get) => ({ if (!device) { return; } - const currentNode = device.nodesMap.get(user.from) ?? create(Protobuf.Mesh.NodeInfoSchema); + const currentNode = device.nodes.get(user.from) ?? + create(Protobuf.Mesh.NodeInfoSchema); currentNode.user = user.data; - currentNode.num = user.from; - device.nodesMap.set(user.from, currentNode); - }) + device.nodes.set(user.from, currentNode); + }), ); }, addPosition: (position) => { @@ -330,11 +493,11 @@ export const useDeviceStore = createStore((set, get) => ({ if (!device) { return; } - const currentNode = device.nodesMap.get(position.from) ?? create(Protobuf.Mesh.NodeInfoSchema); + const currentNode = device.nodes.get(position.from) ?? + create(Protobuf.Mesh.NodeInfoSchema); currentNode.position = position.data; - currentNode.num = position.from; - device.nodesMap.set(position.from, currentNode); - }) + device.nodes.set(position.from, currentNode); + }), ); }, addConnection: (connection) => { @@ -347,13 +510,39 @@ export const useDeviceStore = createStore((set, get) => ({ }), ); }, + addMessage: (message) => { + set( + produce((draft) => { + const device = draft.devices.get(id); + if (!device) { + return; + } + const messageGroup = device.messages[message.type]; + const messageIndex = message.type === "direct" + ? message.from === device.hardware.myNodeNum + ? message.to + : message.from + : message.channel; + const messages = messageGroup.get(messageIndex); + + if (messages) { + messages.push(message); + messageGroup.set(messageIndex, messages); + } else { + messageGroup.set(messageIndex, [message]); + } + }), + ); + }, + addMetadata: (from, metadata) => { set( produce((draft) => { const device = draft.devices.get(id); - if (device) { - device.metadata.set(from, metadata); + if (!device) { + return; } + device.metadata.set(from, metadata); }), ); }, @@ -361,10 +550,17 @@ export const useDeviceStore = createStore((set, get) => ({ set( produce((draft) => { const device = draft.devices.get(id); - if (!device) return; - const routes = device.traceroutes.get(traceroute.from) ?? []; - routes.push(traceroute); - device.traceroutes.set(traceroute.from, routes); + if (!device) { + return; + } + + const nodetraceroutes = device.traceroutes.get(traceroute.from); + if (nodetraceroutes) { + nodetraceroutes.push(traceroute); + device.traceroutes.set(traceroute.from, nodetraceroutes); + } else { + device.traceroutes.set(traceroute.from, [traceroute]); + } }), ); }, @@ -373,38 +569,83 @@ export const useDeviceStore = createStore((set, get) => ({ produce((draft) => { const device = draft.devices.get(id); if (!device) { - return + return; } - device.nodesMap.delete(nodeNum); - })) + device.nodes.delete(nodeNum); + }), + ); + }, + setMessageState: ( + type: "direct" | "broadcast", + channelIndex: Types.ChannelNumber, + to: number, + from: number, + messageId: number, + state: MessageState, + ) => { + set( + produce((draft) => { + const device = draft.devices.get(id); + if (!device) { + return; + } + const messageGroup = device.messages[type]; + + const messageIndex = type === "direct" + ? from === device.hardware.myNodeNum ? to : from + : channelIndex; + const messages = messageGroup.get(messageIndex); + + if (!messages) { + return; + } + + messageGroup.set( + messageIndex, + messages.map((msg) => { + if (msg.id === messageId) { + msg.state = state; + } + return msg; + }), + ); + }), + ); }, setDialogOpen: (dialog: DialogVariant, open: boolean) => { set( produce((draft) => { const device = draft.devices.get(id); - if (device) { - device.dialog[dialog] = open; + if (!device) { + return; } + device.dialog[dialog] = open; }), ); }, getDialogOpen: (dialog: DialogVariant) => { const device = get().devices.get(id); - if (!device) throw new Error(`Device ${id} not found`); + if (!device) { + throw new Error("Device not found"); + } return device.dialog[dialog]; }, processPacket(data: ProcessPacketParams) { set( produce((draft) => { const device = draft.devices.get(id); - if (!device) return; - const node = device.nodesMap.get(data.from); + if (!device) { + return; + } + const node = device.nodes.get(data.from); if (node) { - node.lastHeard = data.time; - node.snr = data.snr; - device.nodesMap.set(data.from, node); + device.nodes.set(data.from, { + ...node, + lastHeard: data.time, + snr: data.snr, + }); } else { - device.nodesMap.set( + device.nodes.set( data.from, create(Protobuf.Mesh.NodeInfoSchema, { num: data.from, @@ -426,98 +667,63 @@ export const useDeviceStore = createStore((set, get) => ({ }), ); }, - setNodeError: (nodeNum, error) => { + setQueueStatus: (status: QueueStatus) => { set( produce((draft) => { const device = draft.devices.get(id); if (device) { - device.nodeErrors.set(nodeNum, { node: nodeNum, error }); + device.queueStatus = status; + device.queueStatus.free >= 10 ? true : false } }), ); }, - clearNodeError: (nodeNum: number) => { + setNodeError: (nodeNum, error) => { set( produce((draft) => { const device = draft.devices.get(id); if (device) { - device.nodeErrors.delete(nodeNum); + device.nodeErrors.set(nodeNum, { node: nodeNum, error }); } }), ); }, - getNodeError: (nodeNum: number) => { - const device = get().devices.get(id); - if (!device) throw new Error(`Device ${id} not found`); - return device.nodeErrors.get(nodeNum); - }, - hasNodeError: (nodeNum: number) => { - const device = get().devices.get(id); - if (!device) throw new Error(`Device ${id} not found`); - return device.nodeErrors.has(nodeNum); - }, - incrementUnread: (nodeNum: number) => { - set( - produce((draft) => { - const device = draft.devices.get(id); - if (!device) return; - const currentCount = device.unreadCounts.get(nodeNum) ?? 0; - device.unreadCounts.set(nodeNum, currentCount + 1); - }) - ); - }, - resetUnread: (nodeNum: number) => { + clearNodeError: (nodeNum: number) => { set( produce((draft) => { const device = draft.devices.get(id); - if (!device) return; - device.unreadCounts.set(nodeNum, 0); - if (device.unreadCounts.get(nodeNum) === 0) { - device.unreadCounts.delete(nodeNum); + if (device) { + device.nodeErrors.delete(nodeNum); } - }) + }), ); }, - getNodes: (filter?: (node: Protobuf.Mesh.NodeInfo) => boolean): Protobuf.Mesh.NodeInfo[] => { - const device = get().devices.get(id); - if (!device) { - return []; - } - const allNodes = Array.from(device.nodesMap.values()).filter( - (node) => node.num !== get().devices.get(id)?.hardware.myNodeNum); - if (filter) { - return allNodes.filter(filter); - } - return allNodes; - }, - getNode: (nodeNum: number): Protobuf.Mesh.NodeInfo | undefined => { + getNodeError: (nodeNum: number) => { const device = get().devices.get(id); if (!device) { - return; + throw new Error("Device not found"); } - if (!device.nodesMap.has(nodeNum)) { - return undefined; - } - return device.nodesMap.get(nodeNum); + return device.nodeErrors.get(nodeNum); }, - getNodesLength: () => { + hasNodeError: (nodeNum: number) => { const device = get().devices.get(id); if (!device) { - return 0; + throw new Error("Device not found"); } - return device.nodesMap.size + return device.nodeErrors.has(nodeNum); }, + }); }), ); const device = get().devices.get(id); + if (!device) { - throw new Error(`Failed to create or retrieve device with ID ${id}`); + throw new Error("Device not found"); } return device; }, - removeDevice: (id) => { set( produce((draft) => { diff --git a/src/core/stores/messageStore/index.ts b/src/core/stores/messageStore/index.ts deleted file mode 100644 index 0dd4934e..00000000 --- a/src/core/stores/messageStore/index.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { create } from 'zustand'; -import { persist } from 'zustand/middleware'; -import { produce } from 'immer'; -import { Types } from '@meshtastic/core'; -import { storageWithMapSupport } from "../storage/indexDB.ts"; -import { ChannelId, ClearMessageParams, ConversationId, GetMessagesParams, Message, MessageId, MessageLogMap, NodeNum, SetMessageStateParams } from "@core/stores/messageStore/types.ts"; - -export enum MessageState { - Ack = "ack", - Waiting = "waiting", - Failed = "failed", -} - -export enum MessageType { - Direct = "direct", - Broadcast = "broadcast", -} - -export function getConversationId(node1: NodeNum, node2: NodeNum): ConversationId { - return [node1, node2].sort((a, b) => a - b).join(':'); -} - -export interface MessageStore { - messages: { - direct: Map; - broadcast: Map; - }; -}; -export interface MessageStore { - messages: MessageStore['messages']; - draft: Map; - nodeNum: number; // This device's node number - activeChat: number; // Represents otherNodeNum for Direct, or channel for Broadcast - chatType: MessageType; - - setNodeNum: (nodeNum: number) => void; - getMyNodeNum: () => number; - setActiveChat: (chat: number) => void; - setChatType: (type: MessageType) => void; - saveMessage: (message: Message) => void; - setMessageState: (params: SetMessageStateParams) => void; - getMessages: (params: GetMessagesParams) => Message[]; - getDraft: (key: Types.Destination) => string; - setDraft: (key: Types.Destination, message: string) => void; - deleteAllMessages: () => void; - clearMessageByMessageId: (params: ClearMessageParams) => void; - clearDraft: (key: Types.Destination) => void; -} - -const CURRENT_STORE_VERSION = 0; - -export const useMessageStore = create()( - // persist( - (set, get) => ({ - messages: { - direct: new Map(), - broadcast: new Map(), - }, - draft: new Map(), - activeChat: 0, - chatType: MessageType.Broadcast, - nodeNum: 0, - setNodeNum: (nodeNum) => { - set(produce((state: MessageStore) => { - state.nodeNum = nodeNum; - })); - }, - getMyNodeNum: () => get().nodeNum, - setActiveChat: (chat) => { - set(produce((state: MessageStore) => { - state.activeChat = chat; - })); - }, - setChatType: (type) => { - set(produce((state: MessageStore) => { - state.chatType = type; - })); - }, - saveMessage: (message: Message) => { - set( - produce((state: MessageStore) => { - if (message.type === MessageType.Direct) { - const conversationId = getConversationId(message.from, message.to); - if (!state.messages.direct.has(conversationId)) { - state.messages.direct.set(conversationId, new Map()); - } - state.messages.direct.get(conversationId)!.set(message.messageId, message); - } else if (message.type === MessageType.Broadcast) { - const channelId = message.channel as ChannelId; - if (!state.messages.broadcast.has(channelId)) { - state.messages.broadcast.set(channelId, new Map()); - } - state.messages.broadcast.get(channelId)!.set(message.messageId, message); - } - }) - ); - }, - - setMessageState: (params: SetMessageStateParams) => { - set( - produce((state: MessageStore) => { - let messageLog: MessageLogMap | undefined; - let targetMessage: Message | undefined; - - if (params.type === MessageType.Direct) { - const conversationId = getConversationId(params.nodeA, params.nodeB); - messageLog = state.messages.direct.get(conversationId); - if (messageLog) { - targetMessage = messageLog.get(params.messageId); - } - } else { // Broadcast - messageLog = state.messages.broadcast.get(params.channelId); - if (messageLog) { - targetMessage = messageLog.get(params.messageId); - } - } - - if (targetMessage) { - targetMessage.state = params.newState ?? MessageState.Ack; - } else { - console.warn(`Message or conversation/channel not found for state update. Params: ${JSON.stringify(params)}`); - } - }) - ); - }, - getMessages: (params: GetMessagesParams): Message[] => { - const state = get(); - let messageMap: MessageLogMap | undefined; - - if (params.type === MessageType.Direct) { - const conversationId = getConversationId(params.nodeA, params.nodeB); - messageMap = state.messages.direct.get(conversationId); - - } else { - messageMap = state.messages.broadcast.get(params.channelId); - } - - if (messageMap === undefined) { - return []; - } - - const messagesArray = Array.from(messageMap.values()); - messagesArray.sort((a, b) => a.date - b.date); - return messagesArray; - }, - - clearMessageByMessageId: (params: ClearMessageParams) => { - set( - produce((state: MessageStore) => { - let messageLog: MessageLogMap | undefined; - let parentMap: Map; - let parentKey: ConversationId | ChannelId; - - if (params.type === MessageType.Direct) { - parentKey = getConversationId(params.nodeA, params.nodeB); - parentMap = state.messages.direct; - messageLog = parentMap.get(parentKey); - } else { - parentKey = params.channelId; - parentMap = state.messages.broadcast; - messageLog = parentMap.get(parentKey); - } - - if (messageLog) { - const deleted = messageLog.delete(params.messageId); - - if (deleted) { - console.log(`Deleted message ${params.messageId} from ${params.type} message ${parentKey}`); - // Clean up empty MessageLogMap and its entry in the parent map - if (messageLog.size === 0) { - parentMap.delete(parentKey); - console.log(`Cleaned up empty message entry for ${parentKey}`); - } - } else { - console.warn(`Message ${params.messageId} not found in ${params.type} chat ${parentKey} for deletion.`); - } - } else { - console.warn(`Message entry ${parentKey} not found for message deletion.`); - } - }) - ); - }, - getDraft: (key) => { - return get().draft.get(key) ?? ''; - }, - setDraft: (key, message) => { - set(produce((state: MessageStore) => { - state.draft.set(key, message); - })); - }, - clearDraft: (key) => { - set(produce((state: MessageStore) => { - state.draft.delete(key); - })); - }, - deleteAllMessages: () => { - set(produce((state: MessageStore) => { - state.messages.direct = new Map(); - state.messages.broadcast = new Map(); - })); - } - }), - // { - // name: 'meshtastic-message-store', - // storage: storageWithMapSupport, - // version: CURRENT_STORE_VERSION, - // partialize: (state) => ({ - // messages: state.messages, - // nodeNum: state.nodeNum, - // }), - // }) -) \ No newline at end of file diff --git a/src/core/stores/messageStore/messageStore.test.ts b/src/core/stores/messageStore/messageStore.test.ts deleted file mode 100644 index 1b9fd4dd..00000000 --- a/src/core/stores/messageStore/messageStore.test.ts +++ /dev/null @@ -1,486 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { - useMessageStore, - MessageType, - MessageState, - getConversationId, -} from './index.ts'; -import type { ConversationId, ChannelId, MessageLogMap, Message } from './types.ts'; -import { Types } from '@meshtastic/core'; - -vi.mock('../storage/indexDB.ts', () => { - let memoryStorage: Record = {}; - return { - storageWithMapSupport: { - getItem: vi.fn(async (name: string): Promise => { - return memoryStorage[name] ?? null; - }), - setItem: vi.fn(async (name: string, value: string): Promise => { - memoryStorage[name] = value; - }), - removeItem: vi.fn(async (name: string): Promise => { - delete memoryStorage[name]; - }), - }, - }; -}); - -const myNodeNum = 111; -const otherNodeNum1 = 222; -const otherNodeNum2 = 333; -const broadcastChannel: ChannelId = 0; - -const directMessageToOther1: Message = { - type: MessageType.Direct, - from: myNodeNum, - to: otherNodeNum1, - channel: 0, - date: Date.now(), - messageId: 101, - state: MessageState.Waiting, - message: 'Hello other 1 from me', -}; - -const directMessageFromOther1: Message = { - type: MessageType.Direct, - from: otherNodeNum1, - to: myNodeNum, - channel: 0, - date: Date.now() + 1000, - messageId: 102, - state: MessageState.Waiting, - message: 'Hello me from other 1', -}; - -const directMessageToOther2: Message = { - type: MessageType.Direct, - from: myNodeNum, - to: otherNodeNum2, - channel: 0, - date: Date.now() + 2000, - messageId: 103, - state: MessageState.Waiting, - message: 'Hello other 2 from me', -}; - -const broadcastMessage1: Message = { - type: MessageType.Broadcast, - from: otherNodeNum1, - to: 0xffffffff, - channel: broadcastChannel, - date: Date.now() + 3000, - messageId: 201, - state: MessageState.Waiting, - message: 'Broadcast message 1', -}; - -const broadcastMessage2: Message = { - type: MessageType.Broadcast, - from: myNodeNum, - to: 0xffffffff, - channel: broadcastChannel, - date: Date.now() + 4000, - messageId: 202, - state: MessageState.Waiting, - message: 'Broadcast message 2', -}; - -describe('useMessageStore', () => { - const initialState = useMessageStore.getState(); - - beforeEach(() => { - useMessageStore.setState({ - ...initialState, - messages: { - direct: new Map(), - broadcast: new Map(), - }, - draft: new Map(), - }, true); - - }); - - it('should have correct initial state', () => { - const state = useMessageStore.getState(); - expect(state.messages.direct).toBeInstanceOf(Map); - expect(state.messages.direct.size).toBe(0); - expect(state.messages.broadcast).toBeInstanceOf(Map); - expect(state.messages.broadcast.size).toBe(0); - expect(state.draft).toBeInstanceOf(Map); - expect(state.draft.size).toBe(0); - expect(state.nodeNum).toBe(0); - expect(state.activeChat).toBe(0); - expect(state.chatType).toBe(MessageType.Broadcast); - }); - - it('should set nodeNum', () => { - useMessageStore.getState().setNodeNum(myNodeNum); - expect(useMessageStore.getState().nodeNum).toBe(myNodeNum); - }); - - it('should set activeChat and chatType', () => { - useMessageStore.getState().setActiveChat(otherNodeNum1); - useMessageStore.getState().setChatType(MessageType.Direct); - expect(useMessageStore.getState().activeChat).toBe(otherNodeNum1); - expect(useMessageStore.getState().chatType).toBe(MessageType.Direct); - }); - - describe('saveMessage', () => { - it('should save a direct message with correct Map structure', () => { - useMessageStore.getState().saveMessage(directMessageToOther1); - const state = useMessageStore.getState(); - const conversationId = getConversationId(directMessageToOther1.from, directMessageToOther1.to); - - // Check if the conversation Map exists - expect(state.messages.direct.has(conversationId)).toBe(true); - const conversationLog = state.messages.direct.get(conversationId); - // Check if the inner Map (MessageLogMap) exists and is a Map - expect(conversationLog).toBeInstanceOf(Map); - // Check if the message exists within the inner Map - expect(conversationLog?.has(directMessageToOther1.messageId)).toBe(true); - // Check the message content - expect(conversationLog?.get(directMessageToOther1.messageId)).toEqual(directMessageToOther1); - }); - - it('should save a broadcast message with correct Map structure', () => { - useMessageStore.getState().saveMessage(broadcastMessage1); - const state = useMessageStore.getState(); - const channelId = broadcastMessage1.channel; - - expect(state.messages.broadcast.has(channelId)).toBe(true); - const channelLog = state.messages.broadcast.get(channelId); - expect(channelLog).toBeInstanceOf(Map); - expect(channelLog?.has(broadcastMessage1.messageId)).toBe(true); - expect(channelLog?.get(broadcastMessage1.messageId)).toEqual(broadcastMessage1); - }); - - it('should save multiple messages correctly', () => { - useMessageStore.getState().saveMessage(directMessageToOther1); - useMessageStore.getState().saveMessage(directMessageFromOther1); - useMessageStore.getState().saveMessage(broadcastMessage1); - - const state = useMessageStore.getState(); - - const convId1 = getConversationId(myNodeNum, otherNodeNum1); - expect(state.messages.direct.get(convId1)?.get(directMessageToOther1.messageId)).toEqual(directMessageToOther1); - - expect(state.messages.direct.get(convId1)?.get(directMessageFromOther1.messageId)).toEqual(directMessageFromOther1); - - const channelId = broadcastMessage1.channel; - expect(state.messages.broadcast.get(channelId)?.get(broadcastMessage1.messageId)).toEqual(broadcastMessage1); - }); - }); - - describe('getMessages', () => { - beforeEach(() => { - useMessageStore.getState().setNodeNum(myNodeNum); - useMessageStore.getState().saveMessage(directMessageToOther1); - useMessageStore.getState().saveMessage(directMessageFromOther1); - useMessageStore.getState().saveMessage(directMessageToOther2); - useMessageStore.getState().saveMessage(broadcastMessage1); - useMessageStore.getState().saveMessage(broadcastMessage2); - }); - - it('should return broadcast messages for a channel, sorted by date', () => { - const messages = useMessageStore.getState().getMessages({ - type: MessageType.Broadcast, - channelId: broadcastChannel - }); - expect(messages).toHaveLength(2); - expect(messages[0]).toEqual(broadcastMessage1); - expect(messages[1]).toEqual(broadcastMessage2); - }); - - it('should return empty array for broadcast if channel has no messages', () => { - const messages = useMessageStore.getState().getMessages({ - type: MessageType.Broadcast, - channelId: Types.ChannelNumber.Channel1 - }); - expect(messages).toEqual([]); - }); - - it('should return combined direct messages for a specific chat pair, sorted by date', () => { - const messages = useMessageStore.getState().getMessages({ - type: MessageType.Direct, - nodeA: myNodeNum, - nodeB: otherNodeNum1 - }); - expect(messages).toHaveLength(2); - expect(messages[0]).toEqual(directMessageToOther1); - expect(messages[1]).toEqual(directMessageFromOther1); - }); - - it('should return only relevant direct messages for a different chat pair', () => { - const messages = useMessageStore.getState().getMessages({ - type: MessageType.Direct, - nodeA: myNodeNum, - nodeB: otherNodeNum2 - }); - expect(messages).toHaveLength(1); - expect(messages[0]).toEqual(directMessageToOther2); - }); - - it('should return empty array for direct chat if no messages exist', () => { - const messages = useMessageStore.getState().getMessages({ - type: MessageType.Direct, - nodeA: myNodeNum, - nodeB: 999 - }); - expect(messages).toEqual([]); - }); - }); - - describe('setMessageState', () => { - beforeEach(() => { - useMessageStore.getState().setNodeNum(myNodeNum); - useMessageStore.getState().saveMessage(directMessageToOther1); - useMessageStore.getState().saveMessage(directMessageFromOther1); - useMessageStore.getState().saveMessage(broadcastMessage1); - }); - - it('should update state for a direct message', () => { - useMessageStore.getState().setMessageState({ - type: MessageType.Direct, - nodeA: directMessageToOther1.from, - nodeB: directMessageToOther1.to, - messageId: directMessageToOther1.messageId, - newState: MessageState.Ack, - }); - const conversationId = getConversationId(directMessageToOther1.from, directMessageToOther1.to); - const message = useMessageStore.getState().messages.direct.get(conversationId)?.get(directMessageToOther1.messageId); - expect(message?.state).toBe(MessageState.Ack); - }); - - it('should update state for another direct message in the same conversation', () => { - useMessageStore.getState().setMessageState({ - type: MessageType.Direct, - nodeA: directMessageFromOther1.from, - nodeB: directMessageFromOther1.to, - messageId: directMessageFromOther1.messageId, - newState: MessageState.Failed, - }); - const conversationId = getConversationId(directMessageFromOther1.from, directMessageFromOther1.to); - const message = useMessageStore.getState().messages.direct.get(conversationId)?.get(directMessageFromOther1.messageId); - expect(message?.state).toBe(MessageState.Failed); - }); - - it('should update state for a broadcast message', () => { - useMessageStore.getState().setMessageState({ - type: MessageType.Broadcast, - channelId: broadcastChannel, - messageId: broadcastMessage1.messageId, - newState: MessageState.Ack, - }); - const message = useMessageStore.getState().messages.broadcast.get(broadcastChannel)?.get(broadcastMessage1.messageId); - expect(message?.state).toBe(MessageState.Ack); - }); - - it('should warn if message is not found (direct)', () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { }); - useMessageStore.getState().setMessageState({ - type: MessageType.Direct, - nodeA: myNodeNum, - nodeB: otherNodeNum1, - messageId: 999, - newState: MessageState.Ack, - }); - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Message or conversation/channel not found for state update')); - warnSpy.mockRestore(); - }); - - it('should warn if message is not found (broadcast)', () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { }); - useMessageStore.getState().setMessageState({ - type: MessageType.Broadcast, - channelId: broadcastChannel, - messageId: 999, - newState: MessageState.Ack, - }); - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Message or conversation/channel not found for state update')); - warnSpy.mockRestore(); - }); - - it('should warn if conversation/channel is not found', () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { }); - useMessageStore.getState().setMessageState({ - type: MessageType.Direct, - nodeA: myNodeNum, - nodeB: 998, - messageId: 101, - newState: MessageState.Ack, - }); - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Message or conversation/channel not found for state update')); - warnSpy.mockRestore(); - }); - }); - - describe('clearMessageByMessageId', () => { - const extraDirectMessageId = 1011; - beforeEach(() => { - useMessageStore.getState().setNodeNum(myNodeNum); - useMessageStore.getState().saveMessage(directMessageToOther1); - useMessageStore.getState().saveMessage(directMessageFromOther1); - useMessageStore.getState().saveMessage(broadcastMessage1); - useMessageStore.getState().saveMessage({ ...directMessageToOther1, messageId: extraDirectMessageId, date: Date.now() + 50 }); - }); - - it('should delete a specific direct message', () => { - const messageIdToDelete = directMessageToOther1.messageId; - const nodeA = directMessageToOther1.from; - const nodeB = directMessageToOther1.to; - const conversationId = getConversationId(nodeA, nodeB); - - useMessageStore.getState().clearMessageByMessageId({ - type: MessageType.Direct, - nodeA: nodeA, - nodeB: nodeB, - messageId: messageIdToDelete - }); - - const state = useMessageStore.getState(); - const conversationLog = state.messages.direct.get(conversationId); - expect(conversationLog?.has(messageIdToDelete)).toBe(false); - expect(conversationLog?.has(extraDirectMessageId)).toBe(true); - expect(conversationLog?.has(directMessageFromOther1.messageId)).toBe(true); - expect(state.messages.direct.has(conversationId)).toBe(true); - - }); - - it('should delete another specific direct message', () => { - const messageIdToDelete = directMessageFromOther1.messageId; - const nodeA = directMessageFromOther1.from; - const nodeB = directMessageFromOther1.to; - const conversationId = getConversationId(nodeA, nodeB); - - useMessageStore.getState().clearMessageByMessageId({ - type: MessageType.Direct, - nodeA: nodeA, - nodeB: nodeB, - messageId: messageIdToDelete - }); - - const state = useMessageStore.getState(); - const conversationLog = state.messages.direct.get(conversationId); - expect(conversationLog?.has(messageIdToDelete)).toBe(false); - expect(conversationLog?.has(directMessageToOther1.messageId)).toBe(true); - expect(conversationLog?.has(extraDirectMessageId)).toBe(true); - }); - - - it('should delete a specific broadcast message', () => { - const messageIdToDelete = broadcastMessage1.messageId; - const channelId = broadcastMessage1.channel; - - useMessageStore.getState().clearMessageByMessageId({ - type: MessageType.Broadcast, - channelId: channelId, - messageId: messageIdToDelete - }); - - const state = useMessageStore.getState(); - expect(state.messages.broadcast.get(channelId)?.get(messageIdToDelete)).toBeUndefined(); - }); - - it('should clean up empty conversation/channel Maps', () => { - const directConvId = getConversationId(directMessageFromOther1.from, directMessageFromOther1.to); - const broadcastChanId = broadcastMessage1.channel; - - useMessageStore.getState().clearMessageByMessageId({ type: MessageType.Direct, nodeA: directMessageToOther1.from, nodeB: directMessageToOther1.to, messageId: directMessageToOther1.messageId }); - useMessageStore.getState().clearMessageByMessageId({ type: MessageType.Direct, nodeA: directMessageFromOther1.from, nodeB: directMessageFromOther1.to, messageId: directMessageFromOther1.messageId }); - useMessageStore.getState().clearMessageByMessageId({ type: MessageType.Direct, nodeA: directMessageToOther1.from, nodeB: directMessageToOther1.to, messageId: extraDirectMessageId }); - - expect(useMessageStore.getState().messages.direct.has(directConvId)).toBe(false); - - useMessageStore.getState().clearMessageByMessageId({ type: MessageType.Broadcast, channelId: broadcastChanId, messageId: broadcastMessage1.messageId }); - - expect(useMessageStore.getState().messages.broadcast.has(broadcastChanId)).toBe(false); - }); - - it('should not error when trying to delete non-existent message', () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { }); - const conversationId = getConversationId(myNodeNum, otherNodeNum1); - - expect(() => { - useMessageStore.getState().clearMessageByMessageId({ - type: MessageType.Direct, - nodeA: myNodeNum, - nodeB: otherNodeNum1, - messageId: 9999 - }); - }).not.toThrow(); - - const state = useMessageStore.getState(); - const conversationLog = state.messages.direct.get(conversationId); - expect(conversationLog?.size).toBe(3); // 101, 102, 1011 - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('not found in direct chat')); - - warnSpy.mockRestore(); - }); - - it('should not error when trying to delete from non-existent conversation/channel', () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { }); - expect(() => { - useMessageStore.getState().clearMessageByMessageId({ - type: MessageType.Direct, - nodeA: myNodeNum, - nodeB: 9998, - messageId: 101 - }); - }).not.toThrow(); - - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Message entry")); - - expect(warnSpy).toHaveBeenCalledTimes(1); - - warnSpy.mockRestore(); - }); - }); - - describe('Drafts', () => { - const draftKeyDirect = otherNodeNum1; - const draftKeyBroadcast = broadcastChannel; - const draftMessage = 'This is a draft'; - - it('should set and get a draft for direct chat', () => { - useMessageStore.getState().setDraft(draftKeyDirect, draftMessage); - expect(useMessageStore.getState().draft.get(draftKeyDirect)).toBe(draftMessage); - expect(useMessageStore.getState().getDraft(draftKeyDirect)).toBe(draftMessage); - }); - - it('should set and get a draft for broadcast chat', () => { - useMessageStore.getState().setDraft(draftKeyBroadcast, draftMessage); - expect(useMessageStore.getState().draft.get(draftKeyBroadcast)).toBe(draftMessage); - expect(useMessageStore.getState().getDraft(draftKeyBroadcast)).toBe(draftMessage); - }); - - it('should return empty string for non-existent draft', () => { - expect(useMessageStore.getState().getDraft(999)).toBe(''); - }); - - it('should clear a draft', () => { - useMessageStore.getState().setDraft(draftKeyDirect, draftMessage); - expect(useMessageStore.getState().draft.has(draftKeyDirect)).toBe(true); - useMessageStore.getState().clearDraft(draftKeyDirect); - expect(useMessageStore.getState().draft.has(draftKeyDirect)).toBe(false); - expect(useMessageStore.getState().getDraft(draftKeyDirect)).toBe(''); - }); - }); - - describe('deleteAllMessages', () => { - it('should clear all direct and broadcast messages, leaving empty Maps', () => { - useMessageStore.getState().saveMessage(directMessageToOther1); - useMessageStore.getState().saveMessage(broadcastMessage1); - - expect(useMessageStore.getState().messages.direct.size).toBeGreaterThan(0); - expect(useMessageStore.getState().messages.broadcast.size).toBeGreaterThan(0); - - useMessageStore.getState().deleteAllMessages(); - - const state = useMessageStore.getState(); - expect(state.messages.direct).toBeInstanceOf(Map); - expect(state.messages.direct.size).toBe(0); - expect(state.messages.broadcast).toBeInstanceOf(Map); - expect(state.messages.broadcast.size).toBe(0); - }); - }); -}); \ No newline at end of file diff --git a/src/core/stores/messageStore/types.ts b/src/core/stores/messageStore/types.ts deleted file mode 100644 index 2b49ba99..00000000 --- a/src/core/stores/messageStore/types.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { Types } from "@meshtastic/core"; -import { MessageState, MessageType } from "@core/stores/messageStore/index.ts"; - -type NodeNum = number; -type MessageId = number; -type ChannelId = Types.ChannelNumber; -type ConversationId = string; -type MessageLogMap = Map; - -interface MessageBase { - channel: Types.ChannelNumber; - to: number; - from: number; - date: number; - messageId: number; - state: MessageState; - message: string; -} - -interface GenericMessage extends MessageBase { - type: T; -} - -type Message = GenericMessage | GenericMessage; - - -type GetMessagesParams = - | { type: MessageType.Direct; nodeA: NodeNum; nodeB: NodeNum } - | { type: MessageType.Broadcast; channelId: ChannelId }; - - -type SetMessageStateParams = - | { - type: MessageType.Direct; - nodeA: NodeNum; - nodeB: NodeNum; - messageId: MessageId; // ID of the message within that chat - newState?: MessageState; // Optional new state, defaults to Ack - } - | { - type: MessageType.Broadcast; - channelId: ChannelId; - messageId: MessageId; - newState?: MessageState; // Optional new state, defaults to Ack - }; - -type ClearMessageParams = - | { - type: MessageType.Direct; - nodeA: NodeNum; - nodeB: NodeNum; - messageId: MessageId; - } - | { - type: MessageType.Broadcast; - channelId: ChannelId; - messageId: MessageId; - }; - -export type { - Message, - ConversationId, - NodeNum, - MessageLogMap, - ChannelId, - MessageId, - GetMessagesParams, - SetMessageStateParams, - ClearMessageParams, -} \ No newline at end of file diff --git a/src/core/stores/sidebarStore.tsx b/src/core/stores/sidebarStore.tsx deleted file mode 100644 index 0c72836b..00000000 --- a/src/core/stores/sidebarStore.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import React, { createContext, useState, useContext, useMemo } from 'react'; - -interface SidebarContextProps { - isCollapsed: boolean; - setIsCollapsed: React.Dispatch>; - toggleSidebar: () => void; -} - -const SidebarContext = createContext(undefined); - -export const SidebarProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { - const [isCollapsed, setIsCollapsed] = useState(false); - - const toggleSidebar = useMemo(() => () => { - setIsCollapsed(prev => !prev); - }, []); - - const value = useMemo(() => ({ - isCollapsed, - setIsCollapsed, - toggleSidebar, - }), [isCollapsed, toggleSidebar]); - - return ( - - {children} - - ); -}; - -export const useSidebar = (): SidebarContextProps => { - const context = useContext(SidebarContext); - if (context === undefined) { - throw new Error('useSidebar must be used within a SidebarProvider'); - } - return context; -}; \ No newline at end of file diff --git a/src/core/stores/storage/indexDB.ts b/src/core/stores/storage/indexDB.ts deleted file mode 100644 index 1fe5dcf6..00000000 --- a/src/core/stores/storage/indexDB.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { PersistStorage, StateStorage, } from "zustand/middleware"; // Added StorageValue for clarity, though not strictly needed in the final signature here -import { get, set, del } from "idb-keyval"; -import { ChannelId, MessageLogMap } from "@core/stores/messageStore/types.ts"; - -type PersistedMessageState = { - messages: { - direct: Map; - broadcast: Map; - }; - nodeNum: number; -}; - -export const zustandIndexDBStorage: StateStorage = { - getItem: async (name: string): Promise => { - return (await get(name)) || null; - }, - setItem: async (name: string, value: string): Promise => { - await set(name, value); - }, - removeItem: async (name: string): Promise => { - await del(name); - }, -}; - - -type SerializedMap = { - __dataType: 'Map'; - value: Array<[K, V]>; -}; -type JsonReplacer = (this: any, key: string, value: unknown) => unknown; -const replacer: JsonReplacer = (_, value) => { - if (value instanceof Map) { - const map = value as Map; - const serialized: SerializedMap = { - __dataType: 'Map', - value: Array.from(map.entries()), - }; - return serialized; - } - return value; -}; -type JsonReviver = (this: any, key: string, value: unknown) => unknown; -function isSerializedMap(value: unknown): value is SerializedMap { - if (typeof value !== 'object' || value === null || Array.isArray(value)) { - return false; - } - const potentialMap = value as Partial; - return potentialMap.__dataType === 'Map' && Array.isArray(potentialMap.value); -} -const reviver: JsonReviver = (_, value) => { - if (isSerializedMap(value)) { - return new Map(value.value); - } - return value; -}; - - -export const storageWithMapSupport: PersistStorage = { - getItem: async (name): Promise => { - const str = await zustandIndexDBStorage.getItem(name); - if (!str) { return null; } - try { - const parsed = JSON.parse(str, reviver) as PersistedMessageState; - return parsed; - } catch (error) { - console.error(`Error parsing persisted state (${name}):`, error); - return null; - } - }, - setItem: async (name, newValue: PersistedMessageState): Promise => { - try { - const str = JSON.stringify(newValue, replacer); - await zustandIndexDBStorage.setItem(name, str); - } catch (error) { - console.error(`Error stringifying or setting persisted state (${name}):`, error); - } - }, - removeItem: async (name): Promise => { - await zustandIndexDBStorage.removeItem(name); - }, -}; \ No newline at end of file diff --git a/src/core/subscriptions.ts b/src/core/subscriptions.ts index ab79aacd..0ad39019 100644 --- a/src/core/subscriptions.ts +++ b/src/core/subscriptions.ts @@ -1,16 +1,15 @@ import type { Device } from "@core/stores/deviceStore.ts"; import { MeshDevice, Protobuf } from "@meshtastic/core"; -import { MessageState, MessageType, type MessageStore } from "./stores/messageStore/index.ts"; -import PacketToMessageDTO from "@core/dto/PacketToMessageDTO.ts"; -import NodeInfoFactory from "@core/dto/NodeNumToNodeInfoDTO.ts"; export const subscribeAll = ( device: Device, connection: MeshDevice, - messageStore: MessageStore ) => { let myNodeNum = 0; + // onLogEvent + // onMeshHeartbeat + connection.events.onDeviceMetadataPacket.subscribe((metadataPacket) => { device.addMetadata(metadataPacket.from, metadataPacket.data); }); @@ -52,7 +51,6 @@ export const subscribeAll = ( connection.events.onMyNodeInfo.subscribe((nodeInfo) => { device.setHardware(nodeInfo); - messageStore.setNodeNum(nodeInfo.myNodeNum); myNodeNum = nodeInfo.myNodeNum; }); @@ -65,8 +63,10 @@ export const subscribeAll = ( }); connection.events.onNodeInfoPacket.subscribe((nodeInfo) => { - const nodeWithUser = NodeInfoFactory.ensureDefaultUser(nodeInfo); - device.addNodeInfo(nodeWithUser); + // toast(`New Node Discovered: ${nodeInfo.user?.shortName ?? "UNK"}`, { + // icon: "🔎" + // }); + device.addNodeInfo(nodeInfo); }); connection.events.onChannelPacket.subscribe((channel) => { @@ -81,22 +81,10 @@ export const subscribeAll = ( connection.events.onMessagePacket.subscribe((messagePacket) => { - // incoming and outgoing messages are handled by this event listener - console.log("Message Packet", messagePacket); - - const dto = new PacketToMessageDTO(messagePacket, myNodeNum); - const message = dto.toMessage(); - messageStore.saveMessage(message); - - if (message.type == MessageType.Direct) { - if (message.to === myNodeNum) { - device.incrementUnread(messagePacket.from); - } - } else if (message.type == MessageType.Broadcast) { - if (message.from !== myNodeNum) { - device.incrementUnread(message.channel); - } - } + device.addMessage({ + ...messagePacket, + state: messagePacket.from !== myNodeNum ? "ack" : "waiting", + }); }); connection.events.onTraceRoutePacket.subscribe((traceRoutePacket) => { @@ -117,13 +105,13 @@ export const subscribeAll = ( }); }); + connection.events.onQueueStatus.subscribe((queueStatus) => { + device.setQueueStatus(queueStatus); + }); connection.events.onRoutingPacket.subscribe((routingPacket) => { if (routingPacket.data.variant.case === "errorReason") { switch (routingPacket.data.variant.value) { - case Protobuf.Mesh.Routing_Error.MAX_RETRANSMIT: - console.error(`Routing Error: ${routingPacket.data.variant.value}`); - break; case Protobuf.Mesh.Routing_Error.NO_CHANNEL: console.error(`Routing Error: ${routingPacket.data.variant.value}`); device.setNodeError(routingPacket.from, Protobuf.Mesh.Routing_Error[routingPacket?.data?.variant?.value]); diff --git a/src/core/utils/string.ts b/src/core/utils/string.ts index 359b360e..2cabf70d 100644 --- a/src/core/utils/string.ts +++ b/src/core/utils/string.ts @@ -29,43 +29,3 @@ export function formatQuantity( return `${numberFormat.format(value)} ${word}`; } - -export interface LengthValidationResult { - isValid: boolean; - currentLength: number | null; -} - -export function validateMaxByteLength(value: string | null | undefined, maxByteLength: number): LengthValidationResult { - // Ensure maxByteLength is valid - if (typeof maxByteLength !== 'number' || !Number.isInteger(maxByteLength) || maxByteLength < 0) { - console.warn('validateMaxByteLength: maxByteLength must be a non-negative integer.'); - return { isValid: false, currentLength: null }; // Cannot validate with invalid limit - } - - // Handle null or undefined input values - if (value === null || value === undefined) { - return { isValid: false, currentLength: null }; - } - - // Check for TextEncoder availability - if (typeof TextEncoder === 'undefined') { - console.error('validateMaxByteLength: TextEncoder API is not available in this environment.'); - return { isValid: false, currentLength: null }; // Cannot determine byte length - } - - try { - // Encode the string to UTF-8 bytes and get the length - const encoder = new TextEncoder(); - const currentLength = encoder.encode(value).length; - // Perform the byte length check - const isValid = currentLength <= maxByteLength; - - // Return the result object - return { isValid, currentLength }; - } catch (error) { - // Handle potential errors during encoding - console.error('validateMaxByteLength: Error encoding string:', error); - return { isValid: false, currentLength: null }; // Encoding failed - } -} - diff --git a/src/index.css b/src/index.css index 6802cff8..d81c2967 100644 --- a/src/index.css +++ b/src/index.css @@ -25,7 +25,6 @@ --brightness-hover: var(--brightnessHover); --brightness-press: var(--brightnessPress); --brightness-disabled: var(--brightnessDisabled); - --sidebar-width: @apply w-50 lg:w-64; } [data-theme="light"] { diff --git a/src/pages/Channels.tsx b/src/pages/Channels.tsx index fbc033bc..66dea48b 100644 --- a/src/pages/Channels.tsx +++ b/src/pages/Channels.tsx @@ -17,8 +17,8 @@ export const getChannelName = (channel: Protobuf.Channel.Channel) => channel.settings?.name.length ? channel.settings?.name : channel.index === 0 - ? "Primary" - : `Ch ${channel.index}`; + ? "Primary" + : `Ch ${channel.index}`; const ChannelsPage = () => { const { channels, setDialogOpen } = useDevice(); @@ -31,20 +31,19 @@ const ChannelsPage = () => { return ( <> + } - label={`Channel: ${currentChannel ? getChannelName(currentChannel) : "Loading..." - }`} + label={`Channel: ${ + currentChannel ? getChannelName(currentChannel) : "Loading..." + }`} actions={[ { - key: "search", icon: ImportIcon, onClick() { setDialogOpen("import", true); }, }, { - key: "import", icon: QrCodeIcon, onClick() { setDialogOpen("QR", true); diff --git a/src/pages/Config/DeviceConfig.tsx b/src/pages/Config/DeviceConfig.tsx index 2ba5fc3d..b97e8640 100644 --- a/src/pages/Config/DeviceConfig.tsx +++ b/src/pages/Config/DeviceConfig.tsx @@ -52,7 +52,7 @@ export const DeviceConfig = () => { return ( - + {tabs.map((tab) => ( { const { workingConfig, workingModuleConfig, connection } = useDevice(); @@ -51,14 +51,11 @@ const ConfigPage = () => { description: `The configuration change ${moduleConfig.payloadVariant.case} has been saved.`, }) - ) ), ); - setIsSaving(false); } await connection?.commitEditSettings(); - } catch (_error) { toast({ title: "Error Saving Config", @@ -69,10 +66,10 @@ const ConfigPage = () => { } }; - const leftSidebar = useMemo( - () => ( + return ( + <> - + { /> - ), []) - - return ( - <> - { No Devices Connect at least one device to get started - - -
    - - {Object.entries(groupedFilterConfigs).map(( - [groupName, groupConfigs], - ) => ( - - - - {groupName} - - - - {groupConfigs.map((cfg) => { - const val = values[cfg.key]; - switch (cfg.type) { - case "boolean": - if (typeof val !== "boolean") return null; - return ( - onChange(cfg.key, v)} - className="pb-1" - labelClassName="dark:text-slate-900" - > - {cfg.label} - - ); - case "range": { - if ( - !Array.isArray(val) || - val.length !== 2 || - typeof val[0] !== "number" || - typeof val[1] !== "number" - ) { - return null; - } - const [min, max] = val; - const [lo, hi] = cfg.bounds; - - let formattedMin = null; - let formattedMax = null; - - // Some filters require special formatting for min/max values - if (cfg.key == "battery" && min == hi) { - formattedMin = "Charging"; - } - if (cfg.key == "battery" && max == hi) { - formattedMax = "Charging"; - } - if (cfg.key == "hopRange" && min == lo) { - formattedMin = "Direct"; - } - if (cfg.key == "lastHeard") { - formattedMin = ( - <> -
    - {min === lo ? "now" : ( - - )} - - ); - - formattedMax = ( - <> - {max === hi ? ">" : ""} - - - ); - } - - return ( -
    - - { - const [newMin, newMax] = newRange; - onChange(cfg.key, [newMin, newMax]); - }} - className="w-full pb-3" - trackClassName="h-1 bg-slate-200 dark:bg-slate-700" - rangeClassName="bg-blue-500" - thumbClassName="w-3 h-3 bg-white border border-slate-400 dark:border-slate-600" - aria-label={`Slider - ${cfg.label}`} - /> -
    - ); - } - case "multi": { - const safeArray = (() => { - if (!Array.isArray(val)) return []; - return val.filter((x): x is string => - typeof x === "string" - ); - })(); - - const allSelected = cfg.options.length > 0 && - cfg.options.every((opt) => safeArray.includes(opt)); - - return ( - -
    - - {cfg.options.map((opt) => ( - - onChange( - cfg.key, - checked - ? [...safeArray, opt] - : safeArray.filter((s) => s !== opt), - )} - > - {opt} - - ))} -
    -
    - ); - } - case "search": - if (typeof val !== "string") return null; - return ( -
    - - - onChange(cfg.key, e.target.value)} - placeholder="Search phrase" - className="w-full px-2 py-1 border rounded shadow-sm dark:bg-slate-200 dark:border-slate-600" - /> -
    - ); - - default: - return null; - } - })} -
    -
    - ))} -
    - - - {children && ( -
    - {children} -
    - )} -
    -
    - - ); -} diff --git a/src/pages/Map/index.tsx b/src/pages/Map/index.tsx index 8b5bbdb2..a3ec823d 100644 --- a/src/pages/Map/index.tsx +++ b/src/pages/Map/index.tsx @@ -18,8 +18,6 @@ import { useMap, } from "react-map-gl/maplibre"; import MapGl from "react-map-gl/maplibre"; -import { useNodeFilters } from "@core/hooks/useNodeFilters.ts"; -import { FilterControl } from "@pages/Map/FilterControl.tsx"; type NodePosition = { latitude: number; @@ -35,7 +33,7 @@ const convertToLatLng = (position: { }); const MapPage = () => { - const { getNodes, waypoints } = useDevice(); + const { nodes, waypoints } = useDevice(); const { theme } = useTheme(); const { default: map } = useMap(); @@ -48,33 +46,13 @@ const MapPage = () => { // Filter out nodes without a valid position const validNodes = useMemo( () => - getNodes( + Array.from(nodes.values()).filter( (node): node is Protobuf.Mesh.NodeInfo => Boolean(node.position?.latitudeI), ), - [getNodes], + [nodes], ); - const { - filters, - defaultState, - onFilterChange, - resetFilters, - filteredNodes, - groupedFilterConfigs, - } = useNodeFilters(validNodes); - - const isDirty = useMemo(() => { - return Object.keys(filters).some((key) => { - const a = filters[key]; - const b = defaultState[key]; - // simple deep‐equal for primitives and [number,number] - return Array.isArray(a) && Array.isArray(b) - ? a[0] !== b[0] || a[1] !== b[1] - : a !== b; - }); - }, [filters, defaultState]); - const handleMarkerClick = useCallback( (node: Protobuf.Mesh.NodeInfo, event: { originalEvent: MouseEvent }) => { event?.originalEvent?.stopPropagation(); @@ -128,12 +106,12 @@ const MapPage = () => { if (center) { map.easeTo(center); } - }, [filteredNodes, map]); + }, [validNodes, map]); // Generate all markers const markers = useMemo( () => - filteredNodes.map((node) => { + validNodes.map((node) => { const position = convertToLatLng(node.position); return ( { ); }), - [filteredNodes, handleMarkerClick], + [validNodes, handleMarkerClick], ); useEffect(() => { @@ -161,7 +139,8 @@ const MapPage = () => { return ( <> - }> + + { longitude={convertToLatLng(selectedNode.position).longitude} latitude={convertToLatLng(selectedNode.position).latitude} onClose={() => setSelectedNode(null)} - className="w-full" > ) : null} - - ); diff --git a/src/pages/Messages.test.tsx b/src/pages/Messages.test.tsx deleted file mode 100644 index e4ed62b2..00000000 --- a/src/pages/Messages.test.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { describe, it, vi, expect } from "vitest"; -import { render, screen, fireEvent, waitFor } from "@testing-library/react"; -import { MessagesPage } from "./Messages.tsx"; -import { useDevice } from "../core/stores/deviceStore"; -import { Protobuf } from "@meshtastic/core"; - -vi.mock("../core/stores/deviceStore", () => ({ - useDevice: vi.fn() -})); - -const mockUseDevice = { - channels: new Map([ - [0, { - index: 0, - settings: { name: "Primary" }, - role: Protobuf.Channel.Channel_Role.PRIMARY - }] - ]), - nodes: new Map([ - [0, { - num: 0, - user: { longName: "Test Node 0", shortName: "TN0", publicKey: "0000" } - }], - [1111, { - num: 1111, - user: { longName: "Test Node 1", shortName: "TN1", publicKey: "12345" } - }], - [2222, { - num: 2222, - user: { longName: "Test Node 2", shortName: "TN2", publicKey: "67890" } - }], - [3333, { - num: 3333, - user: { longName: "Test Node 3", shortName: "TN3", publicKey: "11111" } - }] - ]), - hardware: { myNodeNum: 1 }, - messages: { broadcast: new Map(), direct: new Map() }, - metadata: new Map(), - unreadCounts: new Map([[1111, 3], [2222, 10]]), - resetUnread: vi.fn(), - hasNodeError: vi.fn() -}; - - -describe.skip("Messages Page", () => { - beforeEach(() => { - vi.mocked(useDevice).mockReturnValue(mockUseDevice); - }); - - it("sorts unreads to the top", () => { - render(); - const buttonOrder = screen.getAllByRole("button").filter(b => b.textContent.includes("Test Node")); - expect(buttonOrder[0].textContent).toContain("TN2Test Node 210"); - expect(buttonOrder[1].textContent).toContain("TN1Test Node 13"); - expect(buttonOrder[2].textContent).toContain("TN0Test Node 0"); - expect(buttonOrder[3].textContent).toContain("TN3Test Node 3"); - }); - - it("updates unread when active chat changes", () => { - render(); - const nodeButton = screen.getAllByRole("button").filter(b => b.textContent.includes("TN1Test Node 13"))[0]; - fireEvent.click(nodeButton); - expect(mockUseDevice.resetUnread).toHaveBeenCalledWith(1111, 0); - }); - - it("does not update the incorrect node", async () => { - render(); - const nodeButton = screen.getAllByRole("button").filter(b => b.textContent.includes("TN1Test Node 1"))[0]; - fireEvent.click(nodeButton); - expect(mockUseDevice.resetUnread).toHaveBeenCalledWith(1111, 0); - expect(mockUseDevice.unreadCounts.get(2222)).toBe(10); - }); -}); \ No newline at end of file diff --git a/src/pages/Messages.tsx b/src/pages/Messages.tsx index 074838c4..b492cd0d 100644 --- a/src/pages/Messages.tsx +++ b/src/pages/Messages.tsx @@ -1,221 +1,163 @@ +import { useAppStore } from "../core/stores/appStore.ts"; import { ChannelChat } from "@components/PageComponents/Messages/ChannelChat.tsx"; import { PageLayout } from "@components/PageLayout.tsx"; import { Sidebar } from "@components/Sidebar.tsx"; import { Avatar } from "@components/UI/Avatar.tsx"; import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx"; -import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.tsx"; +import { SidebarButton } from "@components/UI/Sidebar/sidebarButton.tsx"; import { useToast } from "@core/hooks/useToast.ts"; import { useDevice } from "@core/stores/deviceStore.ts"; import { Protobuf, Types } from "@meshtastic/core"; +import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; import { getChannelName } from "@pages/Channels.tsx"; import { HashIcon, LockIcon, LockOpenIcon } from "lucide-react"; -import { useCallback, useDeferredValue, useMemo, useState } from "react"; +import { useState } from "react"; import { MessageInput } from "@components/PageComponents/Messages/MessageInput.tsx"; import { cn } from "@core/utils/cn.ts"; -import { MessageState, MessageType, useMessageStore } from "@core/stores/messageStore/index.ts"; -import { useSidebar } from "@core/stores/sidebarStore.tsx"; -import { Input } from "@components/UI/Input.tsx"; - -type NodeInfoWithUnread = Protobuf.Mesh.NodeInfo & { unreadCount: number }; export const MessagesPage = () => { - const { channels, getNodes, getNode, hasNodeError, unreadCounts, resetUnread, connection } = useDevice(); - const { getMyNodeNum, getMessages, setActiveChat, chatType, activeChat, setChatType, setMessageState, } = useMessageStore() - const { toast } = useToast(); - const { isCollapsed } = useSidebar() + const { channels, nodes, hardware, messages, hasNodeError } = useDevice(); + const { activeChat, chatType, setActiveChat, setChatType } = useAppStore(); const [searchTerm, setSearchTerm] = useState(""); - const deferredSearch = useDeferredValue(searchTerm); - - const filteredNodes = (): NodeInfoWithUnread[] => { - const lowerCaseSearchTerm = deferredSearch.toLowerCase(); - - return getNodes(node => { - const longName = node.user?.longName?.toLowerCase() ?? ''; - const shortName = node.user?.shortName?.toLowerCase() ?? ''; - return longName.includes(lowerCaseSearchTerm) || shortName.includes(lowerCaseSearchTerm) - }) - .map((node) => ({ - ...node, - unreadCount: unreadCounts.get(node.num) ?? 0, - })) - .sort((a, b) => b.unreadCount - a.unreadCount); - } - + const filteredNodes = Array.from(nodes.values()).filter((node) => { + if (node.num === hardware.myNodeNum) return false; + const nodeName = node.user?.longName ?? `!${numberToHexUnpadded(node.num)}`; + return nodeName.toLowerCase().includes(searchTerm.toLowerCase()); + }); const allChannels = Array.from(channels.values()); const filteredChannels = allChannels.filter( (ch) => ch.role !== Protobuf.Channel.Channel_Role.DISABLED, ); const currentChannel = channels.get(activeChat); - const otherNode = getNode(activeChat); - - const isDirect = chatType === MessageType.Direct; - const isBroadcast = chatType === MessageType.Broadcast; - - const sendText = useCallback(async (message: string) => { - const isDirect = chatType === MessageType.Direct; - const toValue = isDirect ? activeChat : MessageType.Broadcast; - - const channelValue = isDirect ? Types.ChannelNumber.Primary : activeChat ?? 0; - - console.log(`Sending message: "${message}" to: ${toValue}, channel: ${channelValue}, type: ${chatType}`); - - let messageId: number | undefined; - - try { - messageId = await connection?.sendText(message, toValue, true, channelValue); - if (messageId !== undefined) { - if (chatType === MessageType.Broadcast) { - setMessageState({ type: chatType, channelId: channelValue, messageId, newState: MessageState.Ack }); - } else { - setMessageState({ type: chatType, nodeA: getMyNodeNum(), nodeB: activeChat, messageId, newState: MessageState.Ack }); - } - } else { - console.warn("sendText completed but messageId is undefined"); - } - // deno-lint-ignore no-explicit-any - } catch (e: any) { - console.error("Failed to send message:", e); - // Note: messageId might be undefined here if the error occurred before it was assigned - if (chatType === MessageType.Broadcast) { - const failedId = messageId ?? `failed-${Date.now()}`; - setMessageState({ type: chatType, channelId: channelValue, messageId: failedId, newState: MessageState.Failed }); - } else { // MessageType.Direct - const failedId = messageId ?? `failed-${Date.now()}`; - setMessageState({ type: chatType, nodeA: getMyNodeNum(), nodeB: activeChat, messageId: failedId, newState: MessageState.Failed }); - } - } - }, [activeChat, chatType, connection, getMyNodeNum, setMessageState]); - - const renderChatContent = () => { - switch (chatType) { - case MessageType.Broadcast: - return ( - - ); - case MessageType.Direct: - return ( - - ); - default: - return ( -
    - Select a channel or node to start messaging. -
    - ); - } - } + const { toast } = useToast(); + const node = nodes.get(activeChat); + const nodeHex = node?.num ? numberToHexUnpadded(node.num) : "Unknown"; - const leftSidebar = useMemo(() => ( - - - {filteredChannels?.map((channel) => ( - { - setChatType(MessageType.Broadcast); - setActiveChat(channel.index); - resetUnread(channel.index); - }} - > - - - ))} - - - ), [filteredChannels, unreadCounts, activeChat, chatType, isCollapsed, setActiveChat, setChatType, resetUnread]); + const messageDestination = chatType === "direct" ? activeChat : "broadcast"; + const messageChannel = chatType === "direct" + ? Types.ChannelNumber.Primary + : activeChat; - const rightSidebar = useMemo(() => ( - - -
    - {filteredNodes()?.map((node) => ( - 0 ? node.unreadCount : undefined} - active={activeChat === node.num && chatType === MessageType.Direct} - onClick={() => { - setChatType(MessageType.Direct); - setActiveChat(node.num); - resetUnread(node.num); - }}> - - - ))} -
    -
    - ), [filteredNodes, searchTerm, activeChat, chatType, setActiveChat, setChatType, resetUnread, hasNodeError]); return ( - -
    - {renderChatContent()} + <> + + + {filteredChannels.map((channel) => ( + { + setChatType("broadcast"); + setActiveChat(channel.index); + }} + element={} + /> + ))} + + +
    + setSearchTerm(e.target.value)} + className="w-full p-2 border border-slate-300 rounded-sm bg-white text-slate-900" + /> +
    +
    + {filteredNodes.map((node) => ( + { + setChatType("direct"); + setActiveChat(node.num); + }} + element={ + + } + /> + ))} +
    +
    +
    +
    + +
    + {chatType === "broadcast" && currentChannel && ( +
    +
    + +
    +
    + )} + + {chatType === "direct" && node && ( +
    +
    + +
    +
    + )} +
    -
    - {(isBroadcast || isDirect) ? ( +
    - ) : ( -
    Select a chat to send a message.
    - )} -
    +
    +
    - + ); }; diff --git a/src/pages/Nodes.tsx b/src/pages/Nodes.tsx index 00374a78..389bf0d0 100644 --- a/src/pages/Nodes.tsx +++ b/src/pages/Nodes.tsx @@ -1,6 +1,7 @@ import { LocationResponseDialog } from "@app/components/Dialog/LocationResponseDialog.tsx"; import { NodeOptionsDialog } from "@app/components/Dialog/NodeOptionsDialog.tsx"; import { TracerouteResponseDialog } from "@app/components/Dialog/TracerouteResponseDialog.tsx"; +import Footer from "@app/components/UI/Footer.tsx"; import { Sidebar } from "@components/Sidebar.tsx"; import { Avatar } from "@components/UI/Avatar.tsx"; import { Mono } from "@components/generic/Mono.tsx"; @@ -10,18 +11,30 @@ import { useDevice } from "@core/stores/deviceStore.ts"; import { Protobuf, type Types } from "@meshtastic/core"; import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; import { LockIcon, LockOpenIcon } from "lucide-react"; -import { type JSX, useCallback, useDeferredValue, useEffect, useState } from "react"; +import { type JSX, useCallback, useEffect, useState } from "react"; import { base16 } from "rfc4648"; -import { Input } from "@components/UI/Input.tsx"; -import { PageLayout } from "@components/PageLayout.tsx"; export interface DeleteNoteDialogProps { open: boolean; onOpenChange: (open: boolean) => void; } +function shortNameFromNode( + node: ReturnType["nodes"][number], +): string { + const shortNameOfNode = node.user?.shortName ?? + (node.user?.macaddr + ? `${ + base16 + .stringify(node.user?.macaddr.subarray(4, 6) ?? []) + .toLowerCase() + }` + : `${numberToHexUnpadded(node.num).slice(-4)}`); + return String(shortNameOfNode); +} + const NodesPage = (): JSX.Element => { - const { getNodes, hardware, connection } = useDevice(); + const { nodes, hardware, connection } = useDevice(); const [selectedNode, setSelectedNode] = useState< Protobuf.Mesh.NodeInfo | undefined >(undefined); @@ -32,16 +45,12 @@ const NodesPage = (): JSX.Element => { Types.PacketMetadata | undefined >(); const [searchTerm, setSearchTerm] = useState(""); - const deferredSearch = useDeferredValue(searchTerm); - const filteredNodes = getNodes(node => { - if (!node.user) return false; - const lowerCaseSearchTerm = deferredSearch.toLowerCase(); - return ( - node.user?.longName?.toLowerCase().includes(lowerCaseSearchTerm) || - node.user?.shortName?.toLowerCase().includes(lowerCaseSearchTerm) - ); - }) + const filteredNodes = Array.from(nodes.values()).filter((node) => { + if (node.num === hardware.myNodeNum) return false; + const nodeName = node.user?.longName ?? `!${numberToHexUnpadded(node.num)}`; + return nodeName.toLowerCase().includes(searchTerm.toLowerCase()); + }); useEffect(() => { if (!connection) return; @@ -76,17 +85,18 @@ const NodesPage = (): JSX.Element => { return ( <> - } className="flex flex-col w-full"> -
    - +
    +
    + setSearchTerm(e.target.value)} + className="w-full p-2 border border-slate-300 rounded-sm bg-white text-slate-900" />
    -
    +
    { ]} rows={filteredNodes.map((node) => [
    - +
    ,

    { onKeyUp={(evt) => { evt.key === "Enter" && setSelectedNode(node); }} - className="cursor-pointer underline ml-2 whitespace-break-spaces" + className="cursor-pointer underline" tabIndex={0} role="button" > - {node.user?.longName ?? numberToHexUnpadded(node.num)} + {node.user?.longName ?? + (node.user?.macaddr + ? `Meshtastic ${ + base16 + .stringify(node.user?.macaddr.subarray(4, 6) ?? []) + .toLowerCase() + }` + : `!${numberToHexUnpadded(node.num)}`)}

    , - + {node.lastHeard !== 0 ? node.viaMqtt === false && node.hopsAway === 0 ? "Direct" - : `${node.hopsAway?.toString()} ${node.hopsAway ?? 0 > 1 ? "hops" : "hop" + : `${node.hopsAway?.toString()} ${ + node.hopsAway > 1 ? "hops" : "hop" } away` : "-"} {node.viaMqtt === true ? ", via MQTT" : ""} @@ -165,7 +183,8 @@ const NodesPage = (): JSX.Element => { onOpenChange={() => setSelectedLocation(undefined)} /> - +
    + ); }; diff --git a/src/tests/setupTests.ts b/src/tests/setupTests.ts index 4682398a..089c4c52 100644 --- a/src/tests/setupTests.ts +++ b/src/tests/setupTests.ts @@ -1,20 +1,12 @@ -import { afterEach, vi } from 'vitest'; +import { expect, afterEach } from 'vitest'; import { cleanup } from '@testing-library/react'; -import { enableMapSet } from "immer"; +import * as matchers from '@testing-library/jest-dom/matchers'; import "@testing-library/jest-dom"; -import '@testing-library/user-event'; -enableMapSet(); +// Enable auto mocks for our UI components +//vi.mock('@components/UI/Dialog.tsx'); +//vi.mock('@components/UI/Typography/Link.tsx'); -vi.mock('idb-keyval', () => ({ - get: vi.fn((key) => Promise.resolve(undefined)), - set: vi.fn((key, value) => Promise.resolve()), - del: vi.fn((key) => Promise.resolve()), - clear: vi.fn(() => Promise.resolve()), - keys: vi.fn(() => Promise.resolve([])), - createStore: vi.fn((dbName, storeName) => ({ - })), -})); globalThis.ResizeObserver = class { observe() { } unobserve() { } diff --git a/vite.config.ts b/vite.config.ts index 4d5bed60..3471597e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -32,10 +32,6 @@ export default defineConfig({ define: { 'import.meta.env.VITE_COMMIT_HASH': JSON.stringify(hash), }, - build: { - emptyOutDir: true, - assetsDir: './', - }, resolve: { alias: { '@app': path.resolve(process.cwd(), './src'), diff --git a/vitest.config.ts b/vitest.config.ts index aaed988c..cbf600bc 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,8 +2,6 @@ import path from "node:path"; import react from '@vitejs/plugin-react'; import { defineConfig } from 'vitest/config' -import { enableMapSet } from "immer"; -enableMapSet(); export default defineConfig({ plugins: [ react(),