diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa399d7c..5cfb85ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,6 +3,7 @@ name: CI on: push: branches: + - main - master permissions: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c3109a69..69a1e0ef 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -name: 'Release' +name: Release on: release: @@ -38,6 +38,12 @@ jobs: name: build path: dist/build.tar + - name: Attach build.tar to release + run: | + gh release upload ${{ github.event.release.tag_name }} dist/build.tar + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Set up QEMU uses: docker/setup-qemu-action@v3 diff --git a/deno.lock b/deno.lock index 7ff40a0e..061fb4a5 100644 --- a/deno.lock +++ b/deno.lock @@ -45,6 +45,7 @@ "npm:crypto-random-string@5": "5.0.0", "npm:gzipper@^8.2.1": "8.2.1", "npm:happy-dom@^17.4.4": "17.4.4", + "npm:idb-keyval@^6.2.1": "6.2.1", "npm:immer@^10.1.1": "10.1.1", "npm:js-cookie@^3.0.5": "3.0.5", "npm:lucide-react@0.486": "0.486.0_react@19.1.0", @@ -4492,6 +4493,9 @@ "https-browserify@1.0.0": { "integrity": "sha512-J+FkSdyD+0mA0N+81tMotaRMfSL9SGi+xpD3T6YApKsc3bGSXJlfXri3VyFOeYkfLRQisDk1W+jIFFKBeUBbBg==" }, + "idb-keyval@6.2.1": { + "integrity": "sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg==" + }, "idb@7.1.1": { "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==" }, @@ -6464,6 +6468,7 @@ "npm:@radix-ui/react-scroll-area@^1.2.3", "npm:@radix-ui/react-select@^2.1.6", "npm:@radix-ui/react-separator@^1.1.2", + "npm:@radix-ui/react-slider@^1.3.2", "npm:@radix-ui/react-switch@^1.1.3", "npm:@radix-ui/react-tabs@^1.1.3", "npm:@radix-ui/react-toast@^1.2.6", @@ -6491,6 +6496,7 @@ "npm:crypto-random-string@5", "npm:gzipper@^8.2.1", "npm:happy-dom@^17.4.4", + "npm:idb-keyval@^6.2.1", "npm:immer@^10.1.1", "npm:js-cookie@^3.0.5", "npm:lucide-react@0.486", diff --git a/package.json b/package.json index 3ebda1bb..a8f7b756 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "@radix-ui/react-scroll-area": "^1.2.3", "@radix-ui/react-select": "^2.1.6", "@radix-ui/react-separator": "^1.1.2", + "@radix-ui/react-slider": "^1.3.2", "@radix-ui/react-switch": "^1.1.3", "@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-toast": "^1.2.6", @@ -62,6 +63,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "crypto-random-string": "^5.0.0", + "idb-keyval": "^6.2.1", "immer": "^10.1.1", "js-cookie": "^3.0.5", "lucide-react": "^0.486.0", diff --git a/public/Logo.svg b/public/Logo.svg new file mode 100644 index 00000000..e6863f6a --- /dev/null +++ b/public/Logo.svg @@ -0,0 +1,16 @@ + + + +Created with Fabric.js 4.6.0 + + + + + + + + + + + + \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 35152fd1..db735aef 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,5 @@ import { DeviceWrapper } from "@app/DeviceWrapper.tsx"; import { PageRouter } from "@app/PageRouter.tsx"; -import { DeviceSelector } from "@components/DeviceSelector.tsx"; import { DialogManager } from "@components/Dialog/DialogManager.tsx"; import { NewDeviceDialog } from "@components/Dialog/NewDeviceDialog.tsx"; import { KeyBackupReminder } from "@components/KeyBackupReminder.tsx"; @@ -14,6 +13,8 @@ import { ErrorBoundary } from "react-error-boundary"; import { ErrorPage } from "@components/UI/ErrorPage.tsx"; import { MapProvider } from "react-map-gl/maplibre"; import { CommandPalette } from "@components/CommandPalette/index.tsx"; +import { SidebarProvider } from "@core/stores/sidebarStore.tsx"; +import { useTheme } from "@core/hooks/useTheme.ts"; export const App = (): JSX.Element => { @@ -23,6 +24,9 @@ export const App = (): JSX.Element => { const device = getDevice(selectedDevice); + // Sets up light/dark mode based on user preferences or system settings + useTheme() + return ( { /> - - - - + + + {device ? ( - + @@ -53,9 +56,9 @@ export const App = (): JSX.Element => { > )} - + - + ); }; diff --git a/src/components/BatteryStatus.tsx b/src/components/BatteryStatus.tsx new file mode 100644 index 00000000..bd6022c9 --- /dev/null +++ b/src/components/BatteryStatus.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import { + PlugZapIcon, + BatteryFullIcon, + BatteryMediumIcon, + BatteryLowIcon, +} from 'lucide-react'; +import { Subtle } from "@components/UI/Typography/Subtle.tsx"; + +interface DeviceMetrics { + batteryLevel?: number | null; + voltage?: number | null; +} + +interface BatteryStatusProps { + deviceMetrics?: DeviceMetrics | null; +} + +interface BatteryStateConfig { + condition: (level: number) => boolean; + Icon: React.ElementType; + className: string; + text: (level: number) => string; +} + +const batteryStates: BatteryStateConfig[] = [ + { + condition: level => level > 100, + Icon: PlugZapIcon, + className: 'text-gray-500', + text: () => 'Plugged in', + }, + { + condition: level => level > 80, + Icon: BatteryFullIcon, + className: 'text-green-500', + text: level => `${level}% charging`, + }, + { + condition: level => level > 20, + Icon: BatteryMediumIcon, + className: 'text-yellow-500', + text: level => `${level}% charging`, + }, + { + condition: () => true, + Icon: BatteryLowIcon, + className: 'text-red-500', + text: level => `${level}% charging`, + }, +]; + +const getBatteryState = (level: number) => { + return batteryStates.find(state => state.condition(level)); +}; + + +const BatteryStatus: React.FC = ({ deviceMetrics }) => { + if (deviceMetrics?.batteryLevel === undefined || deviceMetrics?.batteryLevel === null) { + return null; + } + + const { batteryLevel, voltage } = deviceMetrics; + const currentState = getBatteryState(batteryLevel) ?? batteryStates[batteryStates.length - 1]; + + + const BatteryIcon = currentState.Icon; + const iconClassName = currentState.className; + const statusText = currentState.text(batteryLevel); + + const voltageTitle = `${voltage?.toPrecision(3) ?? 'Unknown'} volts`; + + return ( + + + + {statusText} + + + ); +}; + +export default BatteryStatus; \ No newline at end of file diff --git a/src/components/CommandPalette/index.tsx b/src/components/CommandPalette/index.tsx index 289f07fc..11612984 100644 --- a/src/components/CommandPalette/index.tsx +++ b/src/components/CommandPalette/index.tsx @@ -60,7 +60,7 @@ export const CommandPalette = () => { setSelectedDevice, } = useAppStore(); const { getDevices } = useDeviceStore(); - const { setDialogOpen, setActivePage, connection } = useDevice(); + const { setDialogOpen, setActivePage, getNode, connection } = useDevice(); const { pinnedItems, togglePinnedItem } = usePinnedItems({ storageName: 'pinnedCommandMenuGroups' }); const groups: Group[] = [ @@ -115,12 +115,12 @@ export const CommandPalette = () => { icon: ArrowLeftRightIcon, subItems: getDevices().map((device) => ({ label: - device.nodes.get(device.hardware.myNodeNum)?.user?.longName ?? + getNode(device.hardware.myNodeNum)?.user?.longName ?? device.hardware.myNodeNum.toString(), icon: ( @@ -219,10 +219,10 @@ export const CommandPalette = () => { }, }, { - label: "[WIP] Clear Messages", + label: "Clear All Stored Message", icon: EraserIcon, action() { - alert("This feature is not implemented"); + setDialogOpen("deleteMessages", true); }, }, ], @@ -262,7 +262,7 @@ export const CommandPalette = () => { type="button" onClick={() => togglePinnedItem(group.label)} className={cn( - "transition-all duration-300 scale-100 cursor-pointer m-0.5 p-2 focus:*:data-label:opacity-100" + "transition-all duration-300 scale-100 cursor-pointer p-2 focus:*:data-label:opacity-100" )} aria-description={ pinnedItems.includes(group.label) diff --git a/src/components/DeviceSelector.tsx b/src/components/DeviceSelector.tsx deleted file mode 100644 index 872b9c04..00000000 --- a/src/components/DeviceSelector.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { DeviceSelectorButton } from "@components/DeviceSelectorButton.tsx"; -import ThemeSwitcher from "@components/ThemeSwitcher.tsx"; -import { Separator } from "@components/UI/Seperator.tsx"; -import { Code } from "@components/UI/Typography/Code.tsx"; -import { useAppStore } from "@core/stores/appStore.ts"; -import { useDeviceStore } from "@core/stores/deviceStore.ts"; -import { HomeIcon, PlusIcon, SearchIcon } from "lucide-react"; -import { Avatar } from "@components/UI/Avatar.tsx"; - -export const DeviceSelector = () => { - const { getDevices } = useDeviceStore(); - const { - selectedDevice, - setSelectedDevice, - setCommandPaletteOpen, - setConnectDialogOpen, - } = useAppStore(); - - return ( - - - - { - setSelectedDevice(0); - }} - > - - - {getDevices().map((device) => ( - { - setSelectedDevice(device.id); - }} - active={selectedDevice === device.id} - > - - - ))} - - setConnectDialogOpen(true)} - className="transition-all duration-300" - > - - - - - - - setCommandPaletteOpen(true)} - > - - - {/* TODO: This is being commented out until its fixed */} - { - /* - - */ - } - - {import.meta.env.VITE_COMMIT_HASH} - - - ); -}; diff --git a/src/components/DeviceSelectorButton.tsx b/src/components/DeviceSelectorButton.tsx deleted file mode 100644 index 62c2bc2a..00000000 --- a/src/components/DeviceSelectorButton.tsx +++ /dev/null @@ -1,25 +0,0 @@ -export interface DeviceSelectorButtonProps { - active: boolean; - onClick: () => void; - children?: React.ReactNode; -} - -export const DeviceSelectorButton = ({ - onClick, - children, -}: DeviceSelectorButtonProps) => ( - - { - /* {active && ( - - )} */ - } - - {children} - - -); diff --git a/src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.test.tsx b/src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.test.tsx new file mode 100644 index 00000000..67873be0 --- /dev/null +++ b/src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.test.tsx @@ -0,0 +1,69 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +// Ensure the path is correct for import +import { useMessageStore } from "../../../core/stores/messageStore/index.ts"; +import { DeleteMessagesDialog } from "@components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx"; + +vi.mock('@core/stores/messageStore', () => ({ + useMessageStore: vi.fn(() => ({ + deleteAllMessages: vi.fn(), + })), +})); + +describe('DeleteMessagesDialog', () => { + const mockOnOpenChange = vi.fn(); + const mockClearAllMessages = vi.fn(); + + beforeEach(() => { + mockOnOpenChange.mockClear(); + mockClearAllMessages.mockClear(); + + const mockedUseMessageStore = vi.mocked(useMessageStore); + mockedUseMessageStore.mockImplementation(() => ({ + deleteAllMessages: mockClearAllMessages + })); + mockedUseMessageStore.mockClear(); + + }); + + it('calls onOpenChange with false when the close button (X) is clicked', () => { + render(); + const closeButton = screen.queryByTestId('dialog-close-button'); + if (!closeButton) { + throw new Error("Dialog close button with data-testid='dialog-close-button' not found. Did you add it to the component?"); + } + fireEvent.click(closeButton); + expect(mockOnOpenChange).toHaveBeenCalledTimes(1); + expect(mockOnOpenChange).toHaveBeenCalledWith(false); + }); + + + + it('renders the dialog when open is true', () => { + render(); + expect(screen.getByText('Clear All Messages')).toBeInTheDocument(); + expect(screen.getByText(/This action will clear all message history./)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Dismiss' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Clear Messages' })).toBeInTheDocument(); + }); + + it('does not render the dialog when open is false', () => { + render(); + expect(screen.queryByText('Clear All Messages')).toBeNull(); + }); + + it('calls onOpenChange with false when the dismiss button is clicked', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Dismiss' })); + expect(mockOnOpenChange).toHaveBeenCalledTimes(1); // Add count check + expect(mockOnOpenChange).toHaveBeenCalledWith(false); + }); + + it('calls deleteAllMessages and onOpenChange with false when the clear messages button is clicked', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Clear Messages' })); + expect(mockClearAllMessages).toHaveBeenCalledTimes(1); + expect(mockOnOpenChange).toHaveBeenCalledTimes(1); // Add count check + expect(mockOnOpenChange).toHaveBeenCalledWith(false); + }); +}); \ No newline at end of file diff --git a/src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx b/src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx new file mode 100644 index 00000000..47fe0b92 --- /dev/null +++ b/src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx @@ -0,0 +1,63 @@ + +import { Button } from "@components/UI/Button.tsx"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@components/UI/Dialog.tsx"; +import { AlertTriangleIcon } from "lucide-react"; +import { useMessageStore } from "../../../core/stores/messageStore/index.ts"; + +export interface DeleteMessagesDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export const DeleteMessagesDialog = ({ + open, + onOpenChange, +}: DeleteMessagesDialogProps) => { + const { deleteAllMessages } = useMessageStore(); + const handleCloseDialog = () => { + onOpenChange(false); + }; + + return ( + + + + + + + Clear All Messages + + + This action will clear all message history. This cannot be undone. + Are you sure you want to continue? + + + + + Dismiss + + { + deleteAllMessages(); + handleCloseDialog(); + }} + > + Clear Messages + + + + + ); +}; diff --git a/src/components/Dialog/DeviceNameDialog.tsx b/src/components/Dialog/DeviceNameDialog.tsx index 765e4097..c41c81ba 100644 --- a/src/components/Dialog/DeviceNameDialog.tsx +++ b/src/components/Dialog/DeviceNameDialog.tsx @@ -10,10 +10,11 @@ import { DialogHeader, DialogTitle, } from "@components/UI/Dialog.tsx"; -import { Input } from "@components/UI/Input.tsx"; import { Label } from "@components/UI/Label.tsx"; import { Protobuf } from "@meshtastic/core"; import { useForm } from "react-hook-form"; +import { GenericInput } from "@components/Form/FormInput.tsx"; +import { validateMaxByteLength } from "@core/utils/string.ts"; export interface User { longName: string; @@ -24,32 +25,44 @@ export interface DeviceNameDialogProps { open: boolean; onOpenChange: (open: boolean) => void; } +const MAX_LONG_NAME_BYTE_LENGTH = 40; +const MAX_SHORT_NAME_BYTE_LENGTH = 4; export const DeviceNameDialog = ({ open, onOpenChange, }: DeviceNameDialogProps) => { - const { hardware, nodes, connection } = useDevice(); + const { hardware, getNode, connection } = useDevice(); + const myNode = getNode(hardware.myNodeNum); - const myNode = nodes.get(hardware.myNodeNum); + const defaultValues = { + longName: myNode?.user?.longName ?? "Unknown", + shortName: myNode?.user?.shortName ?? "??", + }; - const { register, handleSubmit } = useForm({ - values: { - longName: myNode?.user?.longName ?? "Unknown", - shortName: myNode?.user?.shortName ?? "Unknown", - }, + const { getValues, setValue, reset, control, handleSubmit } = useForm({ + values: defaultValues, }); + const { currentLength: currentLongNameLength } = validateMaxByteLength(getValues('longName'), MAX_LONG_NAME_BYTE_LENGTH); + const { currentLength: currentShortNameLength } = validateMaxByteLength(getValues('shortName'), MAX_SHORT_NAME_BYTE_LENGTH); + const onSubmit = handleSubmit((data) => { connection?.setOwner( create(Protobuf.Mesh.UserSchema, { - ...myNode?.user, + ...(myNode?.user ?? {}), ...data, }), ); onOpenChange(false); }); + const handleReset = () => { + reset({ longName: "", shortName: "" }); + setValue("longName", ""); + setValue("shortName", ""); + }; + return ( @@ -60,22 +73,51 @@ export const DeviceNameDialog = ({ The Device will restart once the config is saved. - - - Long Name - - Short Name - + + Long Name + - - - - onSubmit()}>Save - + + + Short Name + + + + + Reset + Save + + ); -}; +}; \ No newline at end of file diff --git a/src/components/Dialog/DialogManager.tsx b/src/components/Dialog/DialogManager.tsx index 38e8ff3d..c7cb7605 100644 --- a/src/components/Dialog/DialogManager.tsx +++ b/src/components/Dialog/DialogManager.tsx @@ -10,6 +10,8 @@ import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDeta import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx"; import { RefreshKeysDialog } from "@components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx"; import { RebootOTADialog } from "@components/Dialog/RebootOTADialog.tsx"; +import { DeleteMessagesDialog } from "@components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx"; + export const DialogManager = () => { const { channels, config, dialog, setDialogOpen } = useDevice(); @@ -84,6 +86,12 @@ export const DialogManager = () => { setDialogOpen("rebootOTA", open); }} /> + { + setDialogOpen("deleteMessages", open); + }} + /> > ); }; diff --git a/src/components/Dialog/ImportDialog.tsx b/src/components/Dialog/ImportDialog.tsx index 805d2f3e..242502cd 100644 --- a/src/components/Dialog/ImportDialog.tsx +++ b/src/components/Dialog/ImportDialog.tsx @@ -109,7 +109,6 @@ export const ImportDialog = ({ { setImportDialogInput(e.target.value); }} diff --git a/src/components/Dialog/LocationResponseDialog.tsx b/src/components/Dialog/LocationResponseDialog.tsx index c4df3761..4625fa87 100644 --- a/src/components/Dialog/LocationResponseDialog.tsx +++ b/src/components/Dialog/LocationResponseDialog.tsx @@ -21,9 +21,9 @@ export const LocationResponseDialog = ({ open, onOpenChange, }: LocationResponseDialogProps) => { - const { nodes } = useDevice(); + const { getNode } = useDevice(); - const from = nodes.get(location?.from ?? 0); + const from = getNode(location?.from ?? 0); const longName = from?.user?.longName ?? (from ? `!${numberToHexUnpadded(from?.num)}` : "Unknown"); const shortName = from?.user?.shortName ?? diff --git a/src/components/Dialog/NewDeviceDialog.tsx b/src/components/Dialog/NewDeviceDialog.tsx index 57cca565..b9fc6b1c 100644 --- a/src/components/Dialog/NewDeviceDialog.tsx +++ b/src/components/Dialog/NewDeviceDialog.tsx @@ -22,9 +22,12 @@ import { Subtle } from "@components/UI/Typography/Subtle.tsx"; import { AlertCircle } from "lucide-react"; import { Link } from "../UI/Typography/Link.tsx"; import { Fragment } from "react/jsx-runtime"; +import { useState } from "react"; export interface TabElementProps { closeDialog: () => void; + connectionInProgress: boolean; + setConnectionInProgress: (inProgress: boolean) => void; } export interface TabManifest { @@ -111,25 +114,28 @@ export const NewDeviceDialog = ({ open, onOpenChange, }: NewDeviceProps) => { + const [connectionInProgress, setConnectionInProgress] = + useState(false); const { unsupported } = useBrowserFeatureDetection(); + const tabs: TabManifest[] = [ { label: "HTTP", element: HTTP, - isDisabled: false, + isDisabled: connectionInProgress, }, { label: "Bluetooth", element: BLE, isDisabled: unsupported.includes("Web Bluetooth") || - unsupported.includes("Secure Context"), + unsupported.includes("Secure Context") || connectionInProgress, }, { label: "Serial", element: Serial, isDisabled: unsupported.includes("Web Serial") || - unsupported.includes("Secure Context"), + unsupported.includes("Secure Context") || connectionInProgress, }, ]; @@ -154,7 +160,7 @@ export const NewDeviceDialog = ({ {tab.isDisabled ? : null} - onOpenChange(false)} /> + onOpenChange(false)} setConnectionInProgress={setConnectionInProgress} connectionInProgress={connectionInProgress} /> ))} diff --git a/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.test.tsx b/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.test.tsx index d8d1bea9..2b2edc23 100644 --- a/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.test.tsx +++ b/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.test.tsx @@ -1,12 +1,17 @@ -import { describe, it, vi, expect, beforeEach, Mock } from "vitest"; +import { describe, it, vi, expect, beforeEach } from "vitest"; import { render, screen } from "@testing-library/react"; import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; import { useAppStore } from "@core/stores/appStore.ts"; +import { Protobuf } from "@meshtastic/core"; vi.mock("@core/stores/deviceStore"); vi.mock("@core/stores/appStore"); +const mockUseDevice = vi.mocked(useDevice); +const mockUseAppStore = vi.mocked(useAppStore); + + describe("NodeDetailsDialog", () => { const mockDevice = { num: 1234, @@ -29,17 +34,21 @@ describe("NodeDetailsDialog", () => { voltage: 4.2, uptimeSeconds: 3600, }, - }; + } as unknown as Protobuf.Mesh.NodeInfo; beforeEach(() => { - // Reset mocks before each test vi.resetAllMocks(); - (useDevice as Mock).mockReturnValue({ - nodes: new Map([[1234, mockDevice]]), + mockUseDevice.mockReturnValue({ + getNode: (nodeNum: number) => { + if (nodeNum === 1234) { + return mockDevice; + } + return undefined; + }, }); - (useAppStore as unknown as Mock).mockReturnValue({ + mockUseAppStore.mockReturnValue({ nodeNumDetails: 1234, }); }); @@ -47,27 +56,87 @@ describe("NodeDetailsDialog", () => { it("renders node details correctly", () => { render( { }} />); - expect(screen.getByText(/Node Details for Test Node/i)).toBeInTheDocument(); + expect(screen.getByText(/Node Details for Test Node \(TN\)/i)).toBeInTheDocument(); expect(screen.getByText("Node Number: 1234")).toBeInTheDocument(); + expect(screen.getByText(/Node Hex: !/i)).toBeInTheDocument(); + expect(screen.getByText(/Last Heard:/i)).toBeInTheDocument(); + + expect(screen.getByText(/Coordinates:/i)).toBeInTheDocument(); + const link = screen.getByRole('link', { name: /^45, -75$/ }); + + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute('href', expect.stringContaining('openstreetmap.org')); + expect(screen.getByText(/Altitude: 200m/i)).toBeInTheDocument(); + expect(screen.getByText(/Air TX utilization: 50.12%/i)).toBeInTheDocument(); expect(screen.getByText(/Channel utilization: 75.46%/i)).toBeInTheDocument(); expect(screen.getByText(/Battery level: 88.79%/i)).toBeInTheDocument(); expect(screen.getByText(/Voltage: 4.20V/i)).toBeInTheDocument(); expect(screen.getByText(/Uptime:/i)).toBeInTheDocument(); - expect(screen.getByText(/Coordinates:/i)).toBeInTheDocument(); - expect(screen.getByText("45, -75")).toBeInTheDocument(); - expect(screen.getByText(/Altitude: 200m/i)).toBeInTheDocument(); - expect(screen.getByText(/Role:/i)).toBeInTheDocument(); + + expect(screen.getByText(/All Raw Metrics:/i)).toBeInTheDocument(); + }); it("renders null if device is not found", () => { - (useDevice as Mock).mockReturnValue({ - nodes: new Map(), + const requestedNodeNum = 5678; + + mockUseAppStore.mockReturnValue({ + nodeNumDetails: requestedNodeNum, }); - render( { }} />); + mockUseDevice.mockReturnValue({ + getNode: (nodeNum: number) => { + if (nodeNum === requestedNodeNum) { + return undefined; + } + if (nodeNum === 1234) { + return mockDevice; + } + return undefined; + }, + }); + + const { container } = render( { }} />); + + expect(container.firstChild).toBeNull(); expect(screen.queryByText(/Node Details for/i)).not.toBeInTheDocument(); }); -}); + + it("renders correctly when position is missing", () => { + const nodeWithoutPosition = { ...mockDevice, position: undefined }; + mockUseDevice.mockReturnValue({ getNode: () => nodeWithoutPosition }); + mockUseAppStore.mockReturnValue({ nodeNumDetails: 1234 }); + + render( { }} />); + + expect(screen.queryByText(/Coordinates:/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/Altitude:/i)).not.toBeInTheDocument(); + expect(screen.getByText(/Node Details for Test Node/i)).toBeInTheDocument(); + }); + + it("renders correctly when deviceMetrics are missing", () => { + const nodeWithoutMetrics = { ...mockDevice, deviceMetrics: undefined }; + mockUseDevice.mockReturnValue({ getNode: () => nodeWithoutMetrics }); + mockUseAppStore.mockReturnValue({ nodeNumDetails: 1234 }); + + render( { }} />); + + expect(screen.queryByText(/Device Metrics:/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/Air TX utilization:/i)).not.toBeInTheDocument(); + expect(screen.getByText(/Node Details for Test Node/i)).toBeInTheDocument(); + }); + + it("renders 'Never' for lastHeard when timestamp is 0", () => { + const nodeNeverHeard = { ...mockDevice, lastHeard: 0 }; + mockUseDevice.mockReturnValue({ getNode: () => nodeNeverHeard }); + mockUseAppStore.mockReturnValue({ nodeNumDetails: 1234 }); + + render( { }} />); + + expect(screen.getByText(/Last Heard: Never/i)).toBeInTheDocument(); + }); + +}); \ No newline at end of file diff --git a/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx b/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx index f010fc67..9001c5f1 100644 --- a/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx +++ b/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx @@ -29,10 +29,10 @@ export const NodeDetailsDialog = ({ open, onOpenChange, }: NodeDetailsDialogProps) => { - const { nodes } = useDevice(); + const { getNode } = useDevice(); const { nodeNumDetails } = useAppStore(); - const device = nodes.get(nodeNumDetails); + const device = getNode(nodeNumDetails); if (!device) return null; @@ -131,7 +131,7 @@ export const NodeDetailsDialog = ({ {device.deviceMetrics && ( - + Device Metrics: {deviceMetricsMap.map( diff --git a/src/components/Dialog/NodeOptionsDialog.tsx b/src/components/Dialog/NodeOptionsDialog.tsx index a152d74e..dcf20f00 100644 --- a/src/components/Dialog/NodeOptionsDialog.tsx +++ b/src/components/Dialog/NodeOptionsDialog.tsx @@ -13,6 +13,7 @@ import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; import { TrashIcon } from "lucide-react"; import { Button } from "../UI/Button.tsx"; +import { MessageType, useMessageStore } from "../../core/stores/messageStore/index.ts"; export interface NodeOptionsDialogProps { node: Protobuf.Mesh.NodeInfo | undefined; @@ -29,23 +30,23 @@ export const NodeOptionsDialog = ({ const { setNodeNumToBeRemoved, setNodeNumDetails, - setChatType, - setActiveChat, } = useAppStore(); + const { setChatType, setActiveChat } = useMessageStore(); + + if (!node) return null; + const longName = node?.user?.longName ?? (node ? `!${numberToHexUnpadded(node?.num)}` : "Unknown"); const shortName = node?.user?.shortName ?? (node ? `${numberToHexUnpadded(node?.num).substring(0, 4)}` : "UNK"); function handleDirectMessage() { - if (!node) return; - setChatType("direct"); + setChatType(MessageType.Direct); setActiveChat(node.num); setActivePage("messages"); } function handleRequestPosition() { - if (!node) return; toast({ title: "Requesting position, please wait...", }); @@ -58,7 +59,6 @@ export const NodeOptionsDialog = ({ } function handleTraceroute() { - if (!node) return; toast({ title: "Sending Traceroute, please wait...", }); @@ -92,7 +92,7 @@ export const NodeOptionsDialog = ({ key="remove" variant="destructive" onClick={() => { - setNodeNumToBeRemoved(node.num); + setNodeNumToBeRemoved(node?.num); setDialogOpen("nodeRemoval", true); }} > @@ -103,7 +103,7 @@ export const NodeOptionsDialog = ({ { - setNodeNumDetails(node.num); + setNodeNumDetails(node?.num); setDialogOpen("nodeDetails", true); }} > diff --git a/src/components/Dialog/PkiRegenerateDialog.tsx b/src/components/Dialog/PkiRegenerateDialog.tsx index c4f9e91e..b0a0df16 100644 --- a/src/components/Dialog/PkiRegenerateDialog.tsx +++ b/src/components/Dialog/PkiRegenerateDialog.tsx @@ -10,12 +10,22 @@ import { } from "@components/UI/Dialog.tsx"; export interface PkiRegenerateDialogProps { + text: { + title: string; + description: string; + button: string; + } open: boolean; onOpenChange: () => void; onSubmit: () => void; } export const PkiRegenerateDialog = ({ + text = { + title: "Regenerate Key Pair", + description: "Are you sure you want to regenerate key pair?", + button: "Regenerate", + }, open, onOpenChange, onSubmit, @@ -25,14 +35,14 @@ export const PkiRegenerateDialog = ({ - Regenerate Key pair? + {text?.title} - Are you sure you want to regenerate key pair? + {text?.description} onSubmit()}> - Regenerate + {text?.button} diff --git a/src/components/Dialog/QRDialog.tsx b/src/components/Dialog/QRDialog.tsx index c4eecfe4..6eb86356 100644 --- a/src/components/Dialog/QRDialog.tsx +++ b/src/components/Dialog/QRDialog.tsx @@ -133,8 +133,8 @@ export const QRDialog = ({ ({ - useRefreshKeysDialog: vi.fn(), -})); +vi.mock("@core/stores/messageStore"); +vi.mock("./useRefreshKeysDialog"); -describe("RefreshKeysDialog Component", () => { - let handleCloseDialogMock: Mock; - let handleNodeRemoveMock: Mock; - let onOpenChangeMock: Mock; +const mockUseMessageStore = vi.mocked(useMessageStore); +const mockUseRefreshKeysDialog = vi.mocked(useRefreshKeysDialog); - beforeEach(() => { - handleCloseDialogMock = vi.fn(); - handleNodeRemoveMock = vi.fn(); - onOpenChangeMock = vi.fn(); +const getInitialState = () => useDeviceStore.getInitialState?.() ?? { devices: new Map(), remoteDevices: new Map() }; - (useRefreshKeysDialog as Mock).mockReturnValue({ - handleCloseDialog: handleCloseDialogMock, - handleNodeRemove: handleNodeRemoveMock, - }); - }); +beforeEach(() => { + useDeviceStore.setState(getInitialState(), true); + vi.clearAllMocks(); +}); - it("renders the dialog with correct content", () => { - render(); - expect(screen.getByText("Keys Mismatch")).toBeInTheDocument(); - expect(screen.getByText("Request New Keys")).toBeInTheDocument(); - expect(screen.getByText("Dismiss")).toBeInTheDocument(); - }); +afterEach(() => { + vi.restoreAllMocks(); +}); - it("calls handleNodeRemove when 'Request New Keys' button is clicked", () => { - render(); - fireEvent.click(screen.getByText("Request New Keys")); - expect(handleNodeRemoveMock).toHaveBeenCalled(); - }); +test("renders dialog when there is a node error for the active chat", () => { + const deviceId = 1; + const nodeWithErrorNum = 12345; + const activeChatNum = nodeWithErrorNum; - it("calls handleCloseDialog when 'Dismiss' button is clicked", () => { - render(); - fireEvent.click(screen.getByText("Dismiss")); - expect(handleCloseDialogMock).toHaveBeenCalled(); - }); + const deviceStore = useDeviceStore.getState().addDevice(deviceId); + + deviceStore.addNodeInfo({ + num: nodeWithErrorNum, + user: { + id: nodeWithErrorNum.toString(), + publicKey: new Uint8Array(0), + hwModel: Protobuf.Mesh.HardwareModel.HELTEC_V3, + longName: "Problem Node Long", + shortName: "ProbNode", + isLicensed: false, + macaddr: new Uint8Array(0) + }, + lastHeard: Date.now() / 1000, + snr: 10 + } as Protobuf.Mesh.NodeInfo); + + deviceStore.setNodeError(activeChatNum, "PKI_MISMATCH"); + + const updatedDeviceState = useDeviceStore.getState().getDevice(deviceId); + if (!updatedDeviceState) { + throw new Error("Failed to get updated device state from store for provider"); + } - it("calls onOpenChange when dialog close button is clicked", () => { - render(); - fireEvent.click(screen.getByRole("button", { name: /close/i })); - expect(handleCloseDialogMock).toHaveBeenCalled(); + mockUseMessageStore.mockReturnValue({ activeChat: activeChatNum }); + const mockHandleClose = vi.fn(); + const mockHandleRemove = vi.fn(); + mockUseRefreshKeysDialog.mockReturnValue({ + handleCloseDialog: mockHandleClose, + handleNodeRemove: mockHandleRemove, }); - it("does not render when open is false", () => { - render(); - expect(screen.queryByText("Keys Mismatch")).not.toBeInTheDocument(); + render( + + + + ); + + expect(screen.getByText(/Keys Mismatch - Problem Node Long/)).toBeInTheDocument(); + expect(screen.getByText(/Your node is unable to send a direct message to node: Problem Node Long \(ProbNode\)/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Request New Keys" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Dismiss" })).toBeInTheDocument(); +}); + +test("does not render dialog if no error exists for active chat", () => { + const deviceId = 1; + const activeChatNum = 54321; + + useDeviceStore.getState().addDevice(deviceId); + + const currentDeviceState = useDeviceStore.getState().getDevice(deviceId); + if (!currentDeviceState) throw new Error("Device not found"); + + mockUseMessageStore.mockReturnValue({ activeChat: activeChatNum }); + mockUseRefreshKeysDialog.mockReturnValue({ + handleCloseDialog: vi.fn(), + handleNodeRemove: vi.fn(), }); + + const { container } = render( + + + + ); + + expect(container.firstChild).toBeNull(); }); diff --git a/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx b/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx index d2fc659d..98625ceb 100644 --- a/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx +++ b/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx @@ -8,6 +8,8 @@ import { import { Button } from "@components/UI/Button.tsx"; import { LockKeyholeOpenIcon } from "lucide-react"; import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts"; +import { useDevice } from "@core/stores/deviceStore.ts"; +import { useMessageStore } from "../../../core/stores/messageStore/index.ts"; export interface RefreshKeysDialogProps { open: boolean; @@ -15,16 +17,30 @@ export interface RefreshKeysDialogProps { } export const RefreshKeysDialog = ({ open, onOpenChange }: RefreshKeysDialogProps) => { - + const { activeChat } = useMessageStore(); + const { nodeErrors, getNode } = useDevice(); const { handleCloseDialog, handleNodeRemove } = useRefreshKeysDialog(); + + const nodeErrorNum = nodeErrors.get(activeChat); + + if (!nodeErrorNum) { + return null; + } + + const nodeWithError = getNode(nodeErrorNum.node); + + const text = { + title: `Keys Mismatch - ${nodeWithError?.user?.longName ?? ""}`, + description: `Your node is unable to send a direct message to node: ${nodeWithError?.user?.longName ?? ""} (${nodeWithError?.user?.shortName ?? ""}). This is due to the remote node's current public key does not match the previously stored key for this node.`, + } return ( - Keys Mismatch + {text.title} - Your node is unable to send a direct message to this node. This is due to the remote node's current public key not matching the previously stored key for this node. + {text.description} @@ -40,14 +56,12 @@ export const RefreshKeysDialog = ({ open, onOpenChange }: RefreshKeysDialogProps Request New Keys Dismiss diff --git a/src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.test.ts b/src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.test.ts index b26d0165..b50eaee6 100644 --- a/src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.test.ts +++ b/src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.test.ts @@ -2,12 +2,12 @@ import { renderHook, act } from "@testing-library/react"; import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts"; import { beforeEach, describe, expect, it, Mock, vi } from "vitest"; import { useDevice } from "@core/stores/deviceStore.ts"; +import { useMessageStore } from "@core/stores/messageStore/index.ts"; -vi.mock("@core/stores/appStore.ts", () => ({ - useAppStore: vi.fn(() => ({ activeChat: "chat-123" })), +vi.mock("@core/stores/messageStore", () => ({ + useMessageStore: vi.fn(() => ({ activeChat: "chat-123" })), })); - -vi.mock("@core/stores/deviceStore.ts", () => ({ +vi.mock("@core/stores/deviceStore", () => ({ useDevice: vi.fn(() => ({ removeNode: vi.fn(), setDialogOpen: vi.fn(), @@ -23,46 +23,50 @@ describe("useRefreshKeysDialog Hook", () => { let clearNodeErrorMock: Mock; beforeEach(() => { + vi.clearAllMocks(); + removeNodeMock = vi.fn(); setDialogOpenMock = vi.fn(); - getNodeErrorMock = vi.fn(); + getNodeErrorMock = vi.fn().mockReturnValue(undefined); clearNodeErrorMock = vi.fn(); - (useDevice as Mock).mockReturnValue({ + vi.mocked(useDevice).mockReturnValue({ removeNode: removeNodeMock, setDialogOpen: setDialogOpenMock, getNodeError: getNodeErrorMock, clearNodeError: clearNodeErrorMock, }); + + vi.mocked(useMessageStore).mockReturnValue({ + activeChat: "chat-123" + }); }); it("handleNodeRemove should remove the node and update dialog if there is an error", () => { getNodeErrorMock.mockReturnValue({ node: "node-abc" }); const { result } = renderHook(() => useRefreshKeysDialog()); + act(() => { result.current.handleNodeRemove(); }); - act(() => { - result.current.handleNodeRemove(); - }); - + expect(getNodeErrorMock).toHaveBeenCalledTimes(1); expect(getNodeErrorMock).toHaveBeenCalledWith("chat-123"); + expect(clearNodeErrorMock).toHaveBeenCalledTimes(1); expect(clearNodeErrorMock).toHaveBeenCalledWith("chat-123"); + expect(removeNodeMock).toHaveBeenCalledTimes(1); expect(removeNodeMock).toHaveBeenCalledWith("node-abc"); + expect(setDialogOpenMock).toHaveBeenCalledTimes(1); expect(setDialogOpenMock).toHaveBeenCalledWith("refreshKeys", false); }); it("handleNodeRemove should do nothing if there is no error", () => { - getNodeErrorMock.mockReturnValue(undefined); - const { result } = renderHook(() => useRefreshKeysDialog()); + act(() => { result.current.handleNodeRemove(); }); - act(() => { - result.current.handleNodeRemove(); - }); - + expect(getNodeErrorMock).toHaveBeenCalledTimes(1); + expect(getNodeErrorMock).toHaveBeenCalledWith("chat-123"); + expect(clearNodeErrorMock).not.toHaveBeenCalled(); expect(removeNodeMock).not.toHaveBeenCalled(); expect(setDialogOpenMock).not.toHaveBeenCalled(); - expect(clearNodeErrorMock).not.toHaveBeenCalled(); }); it("handleCloseDialog should close the dialog", () => { @@ -72,6 +76,7 @@ describe("useRefreshKeysDialog Hook", () => { result.current.handleCloseDialog(); }); + expect(setDialogOpenMock).toHaveBeenCalledTimes(1); expect(setDialogOpenMock).toHaveBeenCalledWith("refreshKeys", false); }); -}); +}); \ No newline at end of file diff --git a/src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.ts b/src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.ts index 821aade7..ba4f6740 100644 --- a/src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.ts +++ b/src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.ts @@ -1,10 +1,14 @@ import { useCallback } from "react"; -import { useAppStore } from "@core/stores/appStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts"; +import { useMessageStore } from "@core/stores/messageStore/index.ts"; export function useRefreshKeysDialog() { const { removeNode, setDialogOpen, clearNodeError, getNodeError } = useDevice(); - const { activeChat } = useAppStore(); + const { activeChat } = useMessageStore(); + + const handleCloseDialog = useCallback(() => { + setDialogOpen('refreshKeys', false); + }, [setDialogOpen]); const handleNodeRemove = useCallback(() => { const nodeWithError = getNodeError(activeChat); @@ -12,17 +16,12 @@ export function useRefreshKeysDialog() { return; } clearNodeError(activeChat); - handleCloseDialog();; + handleCloseDialog(); return removeNode(nodeWithError?.node); - }, [activeChat, clearNodeError, setDialogOpen, removeNode]); - - const handleCloseDialog = useCallback(() => { - setDialogOpen('refreshKeys', false); - }, [setDialogOpen]) + }, [activeChat, clearNodeError, getNodeError, removeNode, handleCloseDialog]); return { handleCloseDialog, handleNodeRemove }; - } \ No newline at end of file diff --git a/src/components/Dialog/RemoveNodeDialog.tsx b/src/components/Dialog/RemoveNodeDialog.tsx index 66297f23..66c42dc6 100644 --- a/src/components/Dialog/RemoveNodeDialog.tsx +++ b/src/components/Dialog/RemoveNodeDialog.tsx @@ -21,7 +21,7 @@ export const RemoveNodeDialog = ({ open, onOpenChange, }: RemoveNodeDialogProps) => { - const { connection, nodes, removeNode } = useDevice(); + const { connection, getNode, removeNode } = useDevice(); const { nodeNumToBeRemoved } = useAppStore(); const onSubmit = () => { @@ -42,7 +42,7 @@ export const RemoveNodeDialog = ({ - {nodes.get(nodeNumToBeRemoved)?.user?.longName} + {getNode(nodeNumToBeRemoved)?.user?.longName} diff --git a/src/components/Dialog/ShutdownDialog.tsx b/src/components/Dialog/ShutdownDialog.tsx index 28fdb381..ddc50edb 100644 --- a/src/components/Dialog/ShutdownDialog.tsx +++ b/src/components/Dialog/ShutdownDialog.tsx @@ -41,7 +41,6 @@ export const ShutdownDialog = ({ type="number" value={time} onChange={(e) => setTime(Number.parseInt(e.target.value))} - className="dark:text-slate-900" suffix="Minutes" /> { - const { nodes } = useDevice(); + const { getNode } = useDevice(); const route: number[] = traceroute?.data.route ?? []; const routeBack: number[] = traceroute?.data.routeBack ?? []; - const snrTowards = traceroute?.data.snrTowards ?? []; - const snrBack = traceroute?.data.snrBack ?? []; - const from = nodes.get(traceroute?.from ?? 0); + const snrTowards = (traceroute?.data.snrTowards ?? []).map(snr => snr / 4); + const snrBack = (traceroute?.data.snrBack ?? []).map(snr => snr / 4); + const from = getNode(traceroute?.from ?? 0); const longName = from?.user?.longName ?? (from ? `!${numberToHexUnpadded(from?.num)}` : "Unknown"); const shortName = from?.user?.shortName ?? (from ? `${numberToHexUnpadded(from?.num).substring(0, 4)}` : "UNK"); - const to = nodes.get(traceroute?.to ?? 0); + const to = getNode(traceroute?.to ?? 0); return ( diff --git a/src/components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.test.tsx b/src/components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.test.tsx index bc193646..2661874f 100644 --- a/src/components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.test.tsx +++ b/src/components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.test.tsx @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest'; import { renderHook } from '@testing-library/react'; -import { useUnsafeRolesDialog, UNSAFE_ROLES } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog"; -import { eventBus } from "@core/utils/eventBus"; +import { useUnsafeRolesDialog, UNSAFE_ROLES } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts"; +import { eventBus } from "@core/utils/eventBus.ts"; vi.mock('@core/utils/eventBus', () => ({ eventBus: { diff --git a/src/components/Form/FormInput.tsx b/src/components/Form/FormInput.tsx index 6ecbfcdc..8a2015bc 100644 --- a/src/components/Form/FormInput.tsx +++ b/src/components/Form/FormInput.tsx @@ -3,11 +3,10 @@ import type { GenericFormElementProps, } from "@components/Form/DynamicForm.tsx"; import { Input } from "@components/UI/Input.tsx"; -import type { LucideIcon } from "lucide-react"; -import { Eye, EyeOff } from "lucide-react"; import type { ChangeEventHandler } from "react"; import { useState } from "react"; import { useController, type FieldValues } from "react-hook-form"; +import { cn } from "@core/utils/cn.ts"; export interface InputFieldProps extends BaseFormBuilderProps { type: "text" | "number" | "password"; @@ -17,16 +16,15 @@ export interface InputFieldProps extends BaseFormBuilderProps { prefix?: string; suffix?: string; step?: number; + className?: string; fieldLength?: { min?: number; max?: number; currentValueLength?: number; showCharacterCount?: boolean; }, - action?: { - icon: LucideIcon; - onClick: () => void; - }; + showPasswordToggle?: boolean; + showCopyButton?: boolean; }; } @@ -36,8 +34,6 @@ export function GenericInput({ field, }: GenericFormElementProps>) { const { fieldLength, ...restProperties } = field.properties || {}; - - const [passwordShown, setPasswordShown] = useState(false); const [currentLength, setCurrentLength] = useState(fieldLength?.currentValueLength || 0); const { field: controllerField } = useController({ @@ -45,10 +41,6 @@ export function GenericInput({ control, }); - const togglePasswordVisiblity = () => { - setPasswordShown(!passwordShown); - }; - const handleInputChange = (e: React.ChangeEvent) => { const newValue = e.target.value; @@ -66,25 +58,20 @@ export function GenericInput({ return ( {fieldLength?.showCharacterCount && fieldLength?.max && ( - + {currentLength ?? fieldLength?.currentValueLength}/{fieldLength?.max} )} diff --git a/src/components/Form/FormPasswordGenerator.tsx b/src/components/Form/FormPasswordGenerator.tsx index f4b8fbcb..f1d0b9f4 100644 --- a/src/components/Form/FormPasswordGenerator.tsx +++ b/src/components/Form/FormPasswordGenerator.tsx @@ -4,10 +4,9 @@ import type { } from "@components/Form/DynamicForm.tsx"; import type { ButtonVariant } from "../UI/Button.tsx"; import { Generator } from "@components/UI/Generator.tsx"; -import { Eye, EyeOff } from "lucide-react"; import type { ChangeEventHandler } from "react"; -import { useState } from "react"; import { Controller, type FieldValues } from "react-hook-form"; +import { usePasswordVisibilityToggle } from "@core/hooks/usePasswordVisibilityToggle.ts"; export interface PasswordGeneratorProps extends BaseFormBuilderProps { type: "passwordGenerator"; @@ -15,7 +14,7 @@ export interface PasswordGeneratorProps extends BaseFormBuilderProps { hide?: boolean; bits?: { text: string; value: string; key: string }[]; devicePSKBitCount: number; - inputChange: ChangeEventHandler; + inputChange: ChangeEventHandler | undefined; selectChange: (event: string) => void; actionButtons: { text: string; @@ -23,6 +22,8 @@ export interface PasswordGeneratorProps extends BaseFormBuilderProps { variant: ButtonVariant; className?: string; }[]; + showPasswordToggle?: boolean; + showCopyButton?: boolean; } export function PasswordGenerator({ @@ -30,10 +31,7 @@ export function PasswordGenerator({ field, disabled, }: GenericFormElementProps>) { - const [passwordShown, setPasswordShown] = useState(false); - const togglePasswordVisiblity = () => { - setPasswordShown(!passwordShown); - }; + const { isVisible } = usePasswordVisibilityToggle() return ( ({ control={control} render={({ field: { value, ...rest } }) => ( ({ value={value} variant={field.validationText ? "invalid" : "default"} actionButtons={field.actionButtons} + showPasswordToggle={field.showPasswordToggle} + showCopyButton={field.showCopyButton} {...field.properties} {...rest} disabled={disabled} diff --git a/src/components/Form/FormWrapper.tsx b/src/components/Form/FormWrapper.tsx index 8456e2bd..c9a4da94 100644 --- a/src/components/Form/FormWrapper.tsx +++ b/src/components/Form/FormWrapper.tsx @@ -20,17 +20,16 @@ export const FieldWrapper = ({ }: FieldWrapperProps) => ( - + {/* first column = labels/heading, second column = fields, third column = gutter */} + {label} - - - {description} - - {validationText} - - - {children} - + + {description} + + {validationText} + + + {children} diff --git a/src/components/PageComponents/Channel.tsx b/src/components/PageComponents/Channel.tsx index 1a523ca4..282e8aec 100644 --- a/src/components/PageComponents/Channel.tsx +++ b/src/components/PageComponents/Channel.tsx @@ -1,4 +1,4 @@ -import type { ChannelValidation } from "@app/validation/channel.tsx"; +import type { ChannelValidation } from "@app/validation/channel.ts"; import { create } from "@bufbuild/protobuf"; import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { useToast } from "@core/hooks/useToast.ts"; @@ -97,7 +97,8 @@ export const Channel = ({ channel }: SettingsPanelProps) => { settings: { ...channel?.settings, psk: pass, - moduleSettings: {...channel?.settings?.moduleSettings, + moduleSettings: { + ...channel?.settings?.moduleSettings, positionPrecision: channel?.settings?.moduleSettings?.positionPrecision === undefined ? 10 : channel?.settings?.moduleSettings?.positionPrecision, } }, @@ -142,6 +143,8 @@ export const Channel = ({ channel }: SettingsPanelProps) => { hide: true, properties: { value: pass, + showPasswordToggle: true, + showCopyButton: true, }, }, { @@ -206,6 +209,11 @@ export const Channel = ({ channel }: SettingsPanelProps) => { ]} /> setPreSharedDialogOpen(false)} onSubmit={() => preSharedKeyRegenerate()} diff --git a/src/components/PageComponents/Config/Bluetooth.tsx b/src/components/PageComponents/Config/Bluetooth.tsx index ce02e0f3..a20155fd 100644 --- a/src/components/PageComponents/Config/Bluetooth.tsx +++ b/src/components/PageComponents/Config/Bluetooth.tsx @@ -1,5 +1,5 @@ import { useAppStore } from "../../../core/stores/appStore.ts"; -import type { BluetoothValidation } from "@app/validation/config/bluetooth.tsx"; +import type { BluetoothValidation } from "@app/validation/config/bluetooth.ts"; import { create } from "@bufbuild/protobuf"; import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; diff --git a/src/components/PageComponents/Config/Device/Device.test.tsx b/src/components/PageComponents/Config/Device/Device.test.tsx index f9688e90..90bbbffc 100644 --- a/src/components/PageComponents/Config/Device/Device.test.tsx +++ b/src/components/PageComponents/Config/Device/Device.test.tsx @@ -5,11 +5,11 @@ import { useDevice } from "@core/stores/deviceStore.ts"; import { useUnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts"; import { Protobuf } from "@meshtastic/core"; -vi.mock('@core/stores/deviceStore', () => ({ +vi.mock('@core/stores/deviceStore.ts', () => ({ useDevice: vi.fn() })); -vi.mock('@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog', () => ({ +vi.mock('@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts', () => ({ useUnsafeRolesDialog: vi.fn() })); diff --git a/src/components/PageComponents/Config/Display.tsx b/src/components/PageComponents/Config/Display.tsx index 50ceb9c2..af82e4fd 100644 --- a/src/components/PageComponents/Config/Display.tsx +++ b/src/components/PageComponents/Config/Display.tsx @@ -61,6 +61,12 @@ export const Display = () => { label: "Compass North Top", description: "Fix north to the top of compass", }, + { + type: "toggle", + name: "use12hClock", + label: "12-Hour Clock", + description: "Use 12-hour clock format", + }, { type: "toggle", name: "flipScreen", diff --git a/src/components/PageComponents/Config/Position.tsx b/src/components/PageComponents/Config/Position.tsx index 3792b9e8..0d3bf853 100644 --- a/src/components/PageComponents/Config/Position.tsx +++ b/src/components/PageComponents/Config/Position.tsx @@ -74,7 +74,7 @@ export const Position = () => { name: "positionFlags", value: activeFlags, isChecked: (name: string) => - activeFlags?.includes(name as FlagName) ?? false, + activeFlags?.includes(name as FlagName) ?? false, onValueChange: onPositonFlagChange, label: "Position Flags", placeholder: "Select position flags...", diff --git a/src/components/PageComponents/Config/Security/Security.tsx b/src/components/PageComponents/Config/Security/Security.tsx index 871e0384..6c6b880a 100644 --- a/src/components/PageComponents/Config/Security/Security.tsx +++ b/src/components/PageComponents/Config/Security/Security.tsx @@ -195,11 +195,8 @@ export const Security = () => { ], properties: { value: state.privateKey, - action: { - icon: state.privateKeyVisible ? EyeOff : Eye, - onClick: () => - dispatch({ type: "TOGGLE_PRIVATE_KEY_VISIBILITY" }), - }, + showCopyButton: true, + showPasswordToggle: true, }, }, { @@ -211,6 +208,7 @@ export const Security = () => { "Sent out to other nodes on the mesh to allow them to compute a shared secret key", properties: { value: state.publicKey, + showCopyButton: true, }, }, ], @@ -271,11 +269,7 @@ export const Security = () => { ], properties: { value: state.adminKey, - action: { - icon: state.adminKeyVisible ? EyeOff : Eye, - onClick: () => - dispatch({ type: "TOGGLE_ADMIN_KEY_VISIBILITY" }), - }, + showCopyButton: true, }, }, ], @@ -302,6 +296,11 @@ export const Security = () => { ]} /> dispatch({ type: "SHOW_PRIVATE_KEY_DIALOG", payload: false })} diff --git a/src/components/PageComponents/Connect/BLE.tsx b/src/components/PageComponents/Connect/BLE.tsx index 77b5981c..4e639855 100644 --- a/src/components/PageComponents/Connect/BLE.tsx +++ b/src/components/PageComponents/Connect/BLE.tsx @@ -7,10 +7,12 @@ import { subscribeAll } from "@core/subscriptions.ts"; import { randId } from "@core/utils/randId.ts"; import { BleConnection, ServiceUuid } from "@meshtastic/js"; import { useCallback, useEffect, useState } from "react"; +import { useMessageStore } from "../../../core/stores/messageStore/index.ts"; -export const BLE = ({ closeDialog }: TabElementProps) => { +export const BLE = ({ setConnectionInProgress, closeDialog }: TabElementProps) => { const [bleDevices, setBleDevices] = useState([]); const { addDevice } = useDeviceStore(); + const messageStore = useMessageStore() const { setSelectedDevice } = useAppStore(); const updateBleDeviceList = useCallback(async (): Promise => { @@ -30,7 +32,7 @@ export const BLE = ({ closeDialog }: TabElementProps) => { device: bleDevice, }); device.addConnection(connection); - subscribeAll(device, connection); + subscribeAll(device, connection, messageStore); closeDialog(); }; @@ -41,8 +43,9 @@ export const BLE = ({ closeDialog }: TabElementProps) => { {bleDevices.map((device) => ( { + setConnectionInProgress(true); onConnect(device); }} > @@ -54,8 +57,10 @@ export const BLE = ({ closeDialog }: TabElementProps) => { )} { + + await navigator.bluetooth .requestDevice({ filters: [{ services: [ServiceUuid] }], @@ -65,6 +70,11 @@ export const BLE = ({ closeDialog }: TabElementProps) => { if (exists === -1) { setBleDevices(bleDevices.concat(device)); } + }).catch((error) => { + console.error("Error requesting device:", error); + setConnectionInProgress(false); + }).finally(() => { + setConnectionInProgress(false); }); }} > @@ -72,4 +82,4 @@ export const BLE = ({ closeDialog }: TabElementProps) => { ); -}; +}; \ No newline at end of file diff --git a/src/components/PageComponents/Connect/HTTP.tsx b/src/components/PageComponents/Connect/HTTP.tsx index 092b79bf..c50c94b5 100644 --- a/src/components/PageComponents/Connect/HTTP.tsx +++ b/src/components/PageComponents/Connect/HTTP.tsx @@ -1,6 +1,7 @@ import type { TabElementProps } from "@components/Dialog/NewDeviceDialog.tsx"; import { Button } from "@components/UI/Button.tsx"; import { Input } from "@components/UI/Input.tsx"; +import { Link } from "@components/UI/Typography/Link.tsx"; import { Label } from "@components/UI/Label.tsx"; import { Switch } from "@components/UI/Switch.tsx"; import { useAppStore } from "@core/stores/appStore.ts"; @@ -11,25 +12,28 @@ import { MeshDevice } from "@meshtastic/core"; import { TransportHTTP } from "@meshtastic/transport-http"; import { useState } from "react"; import { useForm, useController } from "react-hook-form"; +import { AlertTriangle } from "lucide-react"; +import { useMessageStore } from "../../../core/stores/messageStore/index.ts"; interface FormData { ip: string; tls: boolean; } -export const HTTP = ({ closeDialog }: TabElementProps) => { +export const HTTP = ({ closeDialog, setConnectionInProgress, connectionInProgress }: TabElementProps) => { const isURLHTTPS = location.protocol === "https:"; const { addDevice } = useDeviceStore(); + const messageStore = useMessageStore(); const { setSelectedDevice } = useAppStore(); const { control, handleSubmit, register } = useForm({ defaultValues: { ip: ["client.meshtastic.org", "localhost"].includes( - window.location.hostname, + globalThis.location.hostname, ) ? "meshtastic.local" - : window.location.host, + : globalThis.location.host, tls: isURLHTTPS ? true : false, }, }); @@ -38,49 +42,85 @@ export const HTTP = ({ closeDialog }: TabElementProps) => { field: { value: tlsValue, onChange: setTLS }, } = useController({ name: "tls", control }); - const [connectionInProgress, setConnectionInProgress] = useState(false); + const [connectionError, setConnectionError] = useState<{ host: string; secure: boolean } | null>(null); const onSubmit = handleSubmit(async (data) => { setConnectionInProgress(true); - const id = randId(); - const device = addDevice(id); - const transport = await TransportHTTP.create(data.ip, data.tls); - const connection = new MeshDevice(transport, id); - connection.configure(); - setSelectedDevice(id); - device.addConnection(connection); - subscribeAll(device, connection); - closeDialog(); + setConnectionError(null); + + try { + const id = randId(); + const transport = await TransportHTTP.create(data.ip, data.tls); + const device = addDevice(id); + const connection = new MeshDevice(transport, id); + connection.configure(); + setSelectedDevice(id); + device.addConnection(connection); + subscribeAll(device, connection, messageStore); + closeDialog(); + } catch (error) { + console.error("Connection error:", error); + // Capture all connection errors regardless of type + setConnectionError({ host: data.ip, secure: data.tls }); + setConnectionInProgress(false); + } }); return ( - + IP Address/Hostname Use HTTPS - + + {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 + + + + + + )} {connectionInProgress ? "Connecting..." : "Connect"} diff --git a/src/components/PageComponents/Connect/Serial.tsx b/src/components/PageComponents/Connect/Serial.tsx index 28085cc2..b9cd7dd5 100644 --- a/src/components/PageComponents/Connect/Serial.tsx +++ b/src/components/PageComponents/Connect/Serial.tsx @@ -8,10 +8,12 @@ import { randId } from "@core/utils/randId.ts"; import { MeshDevice } from "@meshtastic/core"; import { TransportWebSerial } from "@meshtastic/transport-web-serial"; import { useCallback, useEffect, useState } from "react"; +import { useMessageStore } from "../../../core/stores/messageStore/index.ts"; -export const Serial = ({ closeDialog }: TabElementProps) => { +export const Serial = ({ setConnectionInProgress, closeDialog }: TabElementProps) => { const [serialPorts, setSerialPorts] = useState([]); const { addDevice } = useDeviceStore(); + const messageStore = useMessageStore() const { setSelectedDevice } = useAppStore(); const updateSerialPortList = useCallback(async () => { @@ -36,7 +38,7 @@ export const Serial = ({ closeDialog }: TabElementProps) => { const connection = new MeshDevice(transport, id); connection.configure(); device.addConnection(connection); - subscribeAll(device, connection); + subscribeAll(device, connection, messageStore); closeDialog(); }; @@ -50,8 +52,9 @@ export const Serial = ({ closeDialog }: TabElementProps) => { { + setConnectionInProgress(true); await onConnect(port); }} > @@ -65,10 +68,15 @@ export const Serial = ({ closeDialog }: TabElementProps) => { )} { await navigator.serial.requestPort().then((port) => { setSerialPorts(serialPorts.concat(port)); + }).catch((error) => { + console.error("Error requesting port:", error); + setConnectionInProgress(false); + }).finally(() => { + setConnectionInProgress(false); }); }} > diff --git a/src/components/PageComponents/Map/NodeDetail.tsx b/src/components/PageComponents/Map/NodeDetail.tsx index 4e57f75a..aa260e3d 100644 --- a/src/components/PageComponents/Map/NodeDetail.tsx +++ b/src/components/PageComponents/Map/NodeDetail.tsx @@ -7,12 +7,7 @@ import { Mono } from "@components/generic/Mono.tsx"; import { TimeAgo } from "@components/generic/TimeAgo.tsx"; import { Protobuf } from "@meshtastic/core"; import type { Protobuf as ProtobufType } from "@meshtastic/core"; -import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; import { - BatteryChargingIcon, - BatteryFullIcon, - BatteryLowIcon, - BatteryMediumIcon, Dot, LockIcon, LockOpenIcon, @@ -27,34 +22,35 @@ import { TooltipProvider, TooltipTrigger, } from "@radix-ui/react-tooltip"; -import { useAppStore } from "@core/stores/appStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts"; +import { MessageType, useMessageStore } from "../../../core/stores/messageStore/index.ts"; +import BatteryStatus from "@components/BatteryStatus.tsx"; export interface NodeDetailProps { node: ProtobufType.Mesh.NodeInfo; } export const NodeDetail = ({ node }: NodeDetailProps) => { - const { setChatType, setActiveChat } = useAppStore(); + const { setChatType, setActiveChat } = useMessageStore(); const { setActivePage } = useDevice(); - const name = node.user?.longName || `!${numberToHexUnpadded(node.num)}`; + const name = node.user?.longName ?? `UNK`; const shortName = node.user?.shortName ?? "UNK"; const hwModel = node.user?.hwModel ?? 0; const hardwareType = Protobuf.Mesh.HardwareModel[hwModel]?.replaceAll("_", " ") ?? `${hwModel}`; function handleDirectMessage() { - setChatType("direct"); + setChatType(MessageType.Direct); setActiveChat(node.num); setActivePage("messages"); } return ( - + - - + + { // Required to prevent DM tooltip auto-appearing on creation e.stopPropagation(); @@ -80,11 +76,10 @@ export const NodeDetail = ({ node }: NodeDetailProps) => { - @@ -114,24 +109,7 @@ export const NodeDetail = ({ node }: NodeDetailProps) => { {hardwareType !== "UNSET" && {hardwareType}} {!!node.deviceMetrics?.batteryLevel && ( - - {node.deviceMetrics?.batteryLevel > 100 - ? - : node.deviceMetrics?.batteryLevel > 80 - ? - : node.deviceMetrics?.batteryLevel > 20 - ? - : } - - {node.deviceMetrics?.batteryLevel > 100 - ? "Charging" - : `${node.deviceMetrics?.batteryLevel}%`} - - + )} @@ -199,7 +177,7 @@ export const NodeDetail = ({ node }: NodeDetailProps) => { {!!node.deviceMetrics?.airUtilTx && ( Airtime Util - {node.deviceMetrics?.airUtilTx.toPrecision(3)}% + {node.deviceMetrics?.airUtilTx.toPrecision(3)}% )} @@ -207,7 +185,7 @@ export const NodeDetail = ({ node }: NodeDetailProps) => { {node.snr !== 0 && ( SNR - + {node.snr}db {Math.min(Math.max((node.snr + 10) * 5, 0), 100)}% diff --git a/src/components/PageComponents/Messages/ChannelChat.tsx b/src/components/PageComponents/Messages/ChannelChat.tsx index 54e398b6..51bbd4db 100644 --- a/src/components/PageComponents/Messages/ChannelChat.tsx +++ b/src/components/PageComponents/Messages/ChannelChat.tsx @@ -1,77 +1,77 @@ -import { type MessageWithState, useDevice } from "@core/stores/deviceStore.ts"; -import { Message } from "@components/PageComponents/Messages/Message.tsx"; +import { MessageItem } from "@components/PageComponents/Messages/MessageItem.tsx"; import { InboxIcon } from "lucide-react"; import { useCallback, useEffect, useRef } from "react"; +import { Message } from "@core/stores/messageStore/types.ts"; export interface ChannelChatProps { - messages?: MessageWithState[]; + messages?: Message[]; } const EmptyState = () => ( - - + + No Messages ); -export const ChannelChat = ({ - messages = [], -}: ChannelChatProps) => { - const { nodes } = useDevice(); - +export const ChannelChat = ({ messages = [] }: ChannelChatProps) => { const messagesEndRef = useRef(null); - const scrollContainerRef = useRef(null); + const scrollContainerRef = useRef(null); + const userScrolledUpRef = useRef(false); + + const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => { + requestAnimationFrame(() => { + messagesEndRef.current?.scrollIntoView({ behavior }); + }); + }, []); - const scrollToBottom = useCallback(() => { + useEffect(() => { const scrollContainer = scrollContainerRef.current; - if (scrollContainer) { - const isNearBottom = - scrollContainer.scrollHeight - - scrollContainer.scrollTop - - scrollContainer.clientHeight < - 100; + if (!scrollContainer) return; + const isScrolledToBottom = scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight <= 10; - if (isNearBottom) { - messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); - } + if (isScrolledToBottom || !userScrolledUpRef.current) { + scrollToBottom('smooth'); } - }, []); + }, [messages, scrollToBottom]); useEffect(() => { - scrollToBottom(); - }, [scrollToBottom, messages]); + const scrollContainer = scrollContainerRef.current; + const handleScroll = () => { + if (!scrollContainer) return; + const isAtBottom = scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight <= 10; + userScrolledUpRef.current = !isAtBottom; + }; + scrollContainer?.addEventListener('scroll', handleScroll, { passive: true }); + return () => { + scrollContainer?.removeEventListener('scroll', handleScroll); + }; + }, []); if (!messages?.length) { return ( - - - - + + + ); } return ( - - - - {messages?.map((message, index) => ( - 0 && messages[index - 1].from === message.from - } - /> - ))} - - - + + + + {messages?.map((message) => ( + + ))} - + + ); }; \ No newline at end of file diff --git a/src/components/PageComponents/Messages/Message.tsx b/src/components/PageComponents/Messages/Message.tsx deleted file mode 100644 index 25023b8d..00000000 --- a/src/components/PageComponents/Messages/Message.tsx +++ /dev/null @@ -1,175 +0,0 @@ -import { memo, useMemo } from "react"; -import { - Tooltip, - TooltipArrow, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@components/UI/Tooltip.tsx"; -import { - type MessageWithState, - useDeviceStore, -} from "@core/stores/deviceStore.ts"; -import { cn } from "@core/utils/cn.ts"; -import { Avatar } from "@components/UI/Avatar.tsx"; -import type { Protobuf } from "@meshtastic/core"; -import { AlertCircle, CheckCircle2, CircleEllipsis, LucideIcon } from "lucide-react"; - -type MessageStateValue = { - state: string; - icon: LucideIcon; - displayText: string; -} - -type MessageState = MessageWithState["state"]; - -interface MessageProps { - lastMsgSameUser: boolean; - message: MessageWithState; - sender: Protobuf.Mesh.NodeInfo; -} - -interface StatusTooltipProps { - state: MessageState; - children: React.ReactNode; -} - -interface StatusIconProps { - state: MessageState; - className?: string; -} - -const MESSAGE_STATES: Record = { - ACK: { state: 'ack', icon: CheckCircle2, displayText: "Message delivered" }, - WAITING: { state: 'waiting', icon: CircleEllipsis, displayText: "Waiting for delivery" }, - FAILED: { state: 'failed', icon: AlertCircle, displayText: "Delivery failed" }, -}; - -const getMessageState = (state: MessageState): MessageStateValue => { - switch (state) { - case MESSAGE_STATES.ACK.state: - return MESSAGE_STATES.ACK; - case MESSAGE_STATES.WAITING.state: - return MESSAGE_STATES.WAITING; - case MESSAGE_STATES.FAILED.state: - return MESSAGE_STATES.FAILED; - default: - return MESSAGE_STATES.FAILED; - } -} - -const StatusTooltip = ({ state, children }: StatusTooltipProps) => ( - - - {children} - - {getMessageState(state).displayText ?? "An unknown error occurred"}; - - - - -); - -const StatusIcon = ({ state, className, ...otherProps }: StatusIconProps) => { - const msgState = getMessageState(state); - - const isFailed = msgState.state === 'failed' - - const iconClass = cn( - className, - "text-slate-500 dark:text-slate-400 size-5 shrink-0" - ); - - const Icon = msgState.icon; - - return ( - - - - ); -}; - -const TimeDisplay = memo(({ date, className }: { date: Date; className?: string }) => ( - - - {date.toLocaleDateString()} - - - {date.toLocaleTimeString(undefined, { - hour: "2-digit", - minute: "2-digit", - })} - - -)); - -export const Message = memo(({ lastMsgSameUser, message, sender }: MessageProps) => { - const { getDevices } = useDeviceStore(); - - const isDeviceUser = useMemo( - () => - getDevices() - .map((device) => device.nodes.get(device.hardware.myNodeNum)?.num) - .includes(message.from), - [getDevices, message.from] - ); - - const messageUser = sender?.user; - - const getMessageTextStyles = (state: MessageState) => { - const msgState = getMessageState(state); - const isAcknowledged = msgState.state === 'ack' - const isFailed = msgState.state === 'failed' - - 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 messageTextClass = useMemo(() => getMessageTextStyles(message.state), [message.state]); - - - return ( - - - - {!lastMsgSameUser && ( - - - - - {messageUser?.longName} - - - - )} - - - - - {message.data} - - - - - - ); -}); diff --git a/src/components/PageComponents/Messages/MessageActionsMenu.tsx b/src/components/PageComponents/Messages/MessageActionsMenu.tsx new file mode 100644 index 00000000..9f575798 --- /dev/null +++ b/src/components/PageComponents/Messages/MessageActionsMenu.tsx @@ -0,0 +1,91 @@ +import { + Tooltip, + TooltipArrow, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@components/UI/Tooltip.tsx"; +import { cn } from "@core/utils/cn.ts"; +import { SmilePlus, Reply } from "lucide-react"; + +interface MessageActionsMenuProps { + onAddReaction?: () => void; + onReply?: () => void; +} + +export const MessageActionsMenu = ({ + onAddReaction, + onReply +}: MessageActionsMenuProps) => { + const hoverIconBarClass = cn( + "absolute top-2 right-2", + "flex items-center gap-x-1", + "bg-white dark:bg-zinc-800", + "border border-gray-200 dark:border-zinc-600", + "rounded-md shadow-sm p-1", + "opacity-0 group-hover:opacity-100", + "transition-opacity duration-100 ease-in-out", + "z-10" + ); + + const hoverIconButtonClass = cn( + "p-1 rounded", + "text-gray-500 dark:text-gray-400", + "hover:text-gray-700 dark:hover:text-gray-300", + "hover:bg-gray-100 dark:hover:bg-zinc-700", + "cursor-pointer" + ); + + const iconSizeClass = "size-4"; + + return ( + e.stopPropagation()}> + + + + { + e.stopPropagation() + if (onAddReaction) { + onAddReaction(); + } + }} + className={hoverIconButtonClass} + > + + + + + Add Reaction + + + + + + + { + e.stopPropagation() + if (onReply) { + onReply(); + } + }} + className={hoverIconButtonClass} + > + + + + + Reply + + + + + + + ); +}; \ No newline at end of file diff --git a/src/components/PageComponents/Messages/MessageInput.test.tsx b/src/components/PageComponents/Messages/MessageInput.test.tsx index 632d9d29..97eb7542 100644 --- a/src/components/PageComponents/Messages/MessageInput.test.tsx +++ b/src/components/PageComponents/Messages/MessageInput.test.tsx @@ -1,152 +1,222 @@ -import { MessageInput } from '@components/PageComponents/Messages/MessageInput.tsx'; -import { useDevice } from "@core/stores/deviceStore.ts"; -import { vi, describe, it, expect, beforeEach, Mock } from 'vitest'; -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; - -vi.mock("@core/stores/deviceStore.ts", () => ({ - useDevice: vi.fn(), +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { MessageInput, MessageInputProps } from './MessageInput.tsx'; +import { Types } from '@meshtastic/core'; + +vi.mock('@components/UI/Button.tsx', () => ({ + Button: vi.fn(({ type, className, children, onClick, onSubmit, variant, ...rest }) => ( + + {children} + + )), })); -vi.mock("@core/utils/debounce.ts", () => ({ - debounce: (fn: () => void) => fn, +vi.mock('@components/UI/Input.tsx', () => ({ + Input: vi.fn(({ autoFocus, minLength, name, placeholder, value, onChange }) => ( + + )), })); -vi.mock("@components/UI/Button.tsx", () => ({ - Button: ({ children, ...props }: { children: React.ReactNode }) => {children} +const mockSetDraft = vi.fn(); +const mockGetDraft = vi.fn(); +const mockClearDraft = vi.fn(); + +vi.mock('@core/stores/messageStore', () => ({ + useMessageStore: vi.fn(() => ({ + setDraft: mockSetDraft, + getDraft: mockGetDraft, + clearDraft: mockClearDraft, + })), + MessageState: { + Ack: 'ack', + Waiting: 'waiting', + Failed: 'failed', + }, + MessageType: { + Direct: 'direct', + Broadcast: 'broadcast', + }, })); -vi.mock("@components/UI/Input.tsx", () => ({ - Input: (props: any) => +vi.mock('lucide-react', () => ({ + SendIcon: vi.fn(() => ), })); -vi.mock("lucide-react", () => ({ - SendIcon: () => Send -})); - -// TODO: getting an error with this test -describe('MessageInput Component', () => { - const mockProps = { - to: "broadcast" as const, - channel: 0 as const, - maxBytes: 100, +describe('MessageInput', () => { + const mockOnSend = vi.fn(); + const defaultProps: MessageInputProps = { + onSend: mockOnSend, + to: 123, + maxBytes: 256, }; - const mockSetMessageDraft = vi.fn(); - const mockSetMessageState = vi.fn(); - const mockSendText = vi.fn().mockResolvedValue(123); - beforeEach(() => { vi.clearAllMocks(); - (useDevice as Mock).mockReturnValue({ - connection: { - sendText: mockSendText, - }, - setMessageState: mockSetMessageState, - messageDraft: "", - setMessageDraft: mockSetMessageDraft, - hardware: { - myNodeNum: 1234567890, - }, - }); + mockGetDraft.mockReturnValue(''); }); - it('renders correctly with initial state', () => { - render(); + const renderComponent = (props: Partial = {}) => { + render(); + }; + it('should render the input field, byte counter, and send button', () => { + renderComponent(); expect(screen.getByPlaceholderText('Enter Message')).toBeInTheDocument(); + expect(screen.getByTestId('byte-counter')).toBeInTheDocument(); + expect(screen.getByRole('button')).toBeInTheDocument(); expect(screen.getByTestId('send-icon')).toBeInTheDocument(); + }); - expect(screen.getByText('0/100')).toBeInTheDocument(); + it('should initialize with the draft from the store', () => { + const initialDraft = 'Existing draft message'; + mockGetDraft.mockImplementation((key) => { + return key === defaultProps.to ? initialDraft : ''; + }); + + renderComponent(); + + const inputElement = screen.getByPlaceholderText('Enter Message') as HTMLInputElement; + expect(inputElement.value).toBe(initialDraft); + expect(mockGetDraft).toHaveBeenCalledWith(defaultProps.to); + const expectedBytes = new Blob([initialDraft]).size; + expect(screen.getByTestId('byte-counter')).toHaveTextContent(`${expectedBytes}/${defaultProps.maxBytes}`); }); - it('updates local draft and byte count when typing', () => { - render(); + it('should update input value, byte counter, and call setDraft on change within limits', () => { + renderComponent(); + const inputElement = screen.getByPlaceholderText('Enter Message'); + const testMessage = 'Hello there!'; + const expectedBytes = new Blob([testMessage]).size; - const inputField = screen.getByPlaceholderText('Enter Message'); - fireEvent.change(inputField, { target: { value: 'Hello' } }) + fireEvent.change(inputElement, { target: { value: testMessage } }); - expect(screen.getByText('5/100')).toBeInTheDocument(); - expect(inputField).toHaveValue('Hello'); - expect(mockSetMessageDraft).toHaveBeenCalledWith('Hello'); + expect((inputElement as HTMLInputElement).value).toBe(testMessage); + expect(screen.getByTestId('byte-counter')).toHaveTextContent(`${expectedBytes}/${defaultProps.maxBytes}`); + expect(mockSetDraft).toHaveBeenCalledTimes(1); + expect(mockSetDraft).toHaveBeenCalledWith(defaultProps.to, testMessage); }); - it.skip('does not allow input exceeding max bytes', () => { - render(); + it('should NOT update input value or call setDraft if maxBytes is exceeded', () => { + const smallMaxBytes = 5; + renderComponent({ maxBytes: smallMaxBytes }); + const inputElement = screen.getByPlaceholderText('Enter Message'); + const initialValue = '12345'; + const excessiveValue = '123456'; - const inputField = screen.getByPlaceholderText('Enter Message'); + fireEvent.change(inputElement, { target: { value: initialValue } }); + expect((inputElement as HTMLInputElement).value).toBe(initialValue); + expect(mockSetDraft).toHaveBeenCalledWith(defaultProps.to, initialValue); + mockSetDraft.mockClear(); - expect(screen.getByText('0/100')).toBeInTheDocument(); + fireEvent.change(inputElement, { target: { value: excessiveValue } }); - userEvent.type(inputField, 'Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis p') + expect((inputElement as HTMLInputElement).value).toBe(initialValue); + expect(screen.getByTestId('byte-counter')).toHaveTextContent(`${smallMaxBytes}/${smallMaxBytes}`); + expect(mockSetDraft).not.toHaveBeenCalled(); + }); - expect(screen.getByText('100/100')).toBeInTheDocument(); - expect(inputField).toHaveValue('Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean m'); + it('should call onSend, clear input, reset byte counter, and call clearDraft on valid submit', async () => { + renderComponent(); + const inputElement = screen.getByPlaceholderText('Enter Message'); + const formElement = screen.getByRole('form'); + const testMessage = 'Send this message'; + + fireEvent.change(inputElement, { target: { value: testMessage } }); + fireEvent.submit(formElement); + + await waitFor(() => { + expect(mockOnSend).toHaveBeenCalledTimes(1); + expect(mockOnSend).toHaveBeenCalledWith(testMessage); + expect((inputElement as HTMLInputElement).value).toBe(''); + expect(screen.getByTestId('byte-counter')).toHaveTextContent(`0/${defaultProps.maxBytes}`); + expect(mockClearDraft).toHaveBeenCalledTimes(1); + expect(mockClearDraft).toHaveBeenCalledWith(defaultProps.to); + }); }); - it.skip('sends message and resets form when submitting', async () => { - try { - render(); + it('should trim whitespace before calling onSend', async () => { + renderComponent(); + const inputElement = screen.getByPlaceholderText('Enter Message'); + const formElement = screen.getByRole('form'); + const testMessageWithWhitespace = ' Trim me! '; + const expectedTrimmedMessage = 'Trim me!'; - const inputField = screen.getByPlaceholderText('Enter Message'); - const submitButton = screen.getByText('Send'); + fireEvent.change(inputElement, { target: { value: testMessageWithWhitespace } }); + fireEvent.submit(formElement); - fireEvent.change(inputField, { target: { value: 'Test Message' } }); - fireEvent.click(submitButton); + await waitFor(() => { + expect(mockOnSend).toHaveBeenCalledTimes(1); + expect(mockOnSend).toHaveBeenCalledWith(expectedTrimmedMessage); + expect(mockClearDraft).toHaveBeenCalledWith(defaultProps.to); + }); + }); - const form = screen.getByRole('form'); - fireEvent.submit(form); + it('should not call onSend or clearDraft if input is empty on submit', async () => { + renderComponent(); + const inputElement = screen.getByPlaceholderText('Enter Message'); + const formElement = screen.getByRole('form'); - expect(mockSendText).toHaveBeenCalledWith('Test message', 'broadcast', true, 0); + expect((inputElement as HTMLInputElement).value).toBe(''); - await waitFor(() => { - expect(mockSetMessageState).toHaveBeenCalledWith( - 'broadcast', - 0, - 'broadcast', - 1234567890, - 123, - 'ack' - ); + fireEvent.submit(formElement); - }); + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 50)); + }); - expect(inputField).toHaveValue(''); - expect(screen.getByText('0/100')).toBeInTheDocument(); - expect(mockSetMessageDraft).toHaveBeenCalledWith(''); - } catch (e) { - console.error(e); - } + expect(mockOnSend).not.toHaveBeenCalled(); + expect(mockClearDraft).not.toHaveBeenCalled(); }); - it('prevents sending empty messages', () => { - render(); - const form = screen.getByPlaceholderText('Enter Message') - fireEvent.submit(form); + it('should not call onSend or clearDraft if input contains only whitespace on submit', async () => { + renderComponent(); + const inputElement = screen.getByTestId('message-input-field'); + const formElement = screen.getByRole('form'); + const whitespaceMessage = ' \t '; - expect(mockSendText).not.toHaveBeenCalled(); - }); + fireEvent.change(inputElement, { target: { value: whitespaceMessage } }); + expect((inputElement as HTMLInputElement).value).toBe(whitespaceMessage); - it('initializes with existing message draft', () => { - (useDevice as Mock).mockReturnValue({ - connection: { - sendText: mockSendText, - }, - setMessageState: mockSetMessageState, - messageDraft: "Existing draft", - setMessageDraft: mockSetMessageDraft, - isQueueingMessages: false, - queueStatus: { free: 10 }, - hardware: { - myNodeNum: 1234567890, - }, + fireEvent.submit(formElement); + + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 50)); }); - render(); + expect(mockOnSend).not.toHaveBeenCalled(); + expect(mockClearDraft).not.toHaveBeenCalled(); + + expect((inputElement as HTMLInputElement).value).toBe(whitespaceMessage); + }); + + it('should work with broadcast destination for drafts', () => { + const broadcastDest: Types.Destination = 'broadcast'; + mockGetDraft.mockImplementation((key) => key === broadcastDest ? 'Broadcast draft' : ''); + + renderComponent({ to: broadcastDest }); + + expect(mockGetDraft).toHaveBeenCalledWith(broadcastDest); + expect((screen.getByPlaceholderText('Enter Message') as HTMLInputElement).value).toBe('Broadcast draft'); + + const inputElement = screen.getByPlaceholderText('Enter Message'); + const formElement = screen.getByRole('form'); + const newMessage = 'New broadcast msg'; + + fireEvent.change(inputElement, { target: { value: newMessage } }); + expect(mockSetDraft).toHaveBeenCalledWith(broadcastDest, newMessage); - const inputField = screen.getByRole('textbox'); + fireEvent.submit(formElement); - expect(inputField).toHaveValue('Existing draft'); + expect(mockOnSend).toHaveBeenCalledWith(newMessage); + expect(mockClearDraft).toHaveBeenCalledWith(broadcastDest); }); }); \ No newline at end of file diff --git a/src/components/PageComponents/Messages/MessageInput.tsx b/src/components/PageComponents/Messages/MessageInput.tsx index b885afaf..c229d8aa 100644 --- a/src/components/PageComponents/Messages/MessageInput.tsx +++ b/src/components/PageComponents/Messages/MessageInput.tsx @@ -1,101 +1,57 @@ -import { debounce } from "@core/utils/debounce.ts"; import { Button } from "@components/UI/Button.tsx"; import { Input } from "@components/UI/Input.tsx"; -import { useDevice } from "@core/stores/deviceStore.ts"; import type { Types } from "@meshtastic/core"; import { SendIcon } from "lucide-react"; -import { startTransition, useCallback, useMemo, useState } from "react"; +import { startTransition, useState } from "react"; +import { useMessageStore } from "@core/stores/messageStore/index.ts"; export interface MessageInputProps { + onSend: (message: string) => void; to: Types.Destination; - channel: Types.ChannelNumber; maxBytes: number; } export const MessageInput = ({ + onSend, to, - channel, maxBytes, }: MessageInputProps) => { - const { - connection, - setMessageState, - messageDraft, - setMessageDraft, - isQueueingMessages, - queueStatus, - hardware, - } = useDevice(); - const myNodeNum = hardware.myNodeNum; - const [localDraft, setLocalDraft] = useState(messageDraft); - const [messageBytes, setMessageBytes] = useState(0); + const { setDraft, getDraft, clearDraft } = useMessageStore(); - const debouncedSetMessageDraft = useMemo( - () => debounce(setMessageDraft, 300), - [setMessageDraft], - ); - - // sends the message to the selected destination - const sendText = useCallback( - async (message: string) => { + const calculateBytes = (text: string) => new Blob([text]).size; - await connection - ?.sendText(message, to, true, channel) - .then((id: number) => - setMessageState( - to === "broadcast" ? "broadcast" : "direct", - channel, - to as number, - myNodeNum, - id, - "ack", - ) - ) - .catch((e: Types.PacketError) => - setMessageState( - to === "broadcast" ? "broadcast" : "direct", - channel, - to as number, - myNodeNum, - e.id, - e.error, - ) - ); - }, - [channel, connection, myNodeNum, setMessageState, to, queueStatus], - ); + const initialDraft = getDraft(to); + const [localDraft, setLocalDraft] = useState(initialDraft); + const [messageBytes, setMessageBytes] = useState(() => calculateBytes(initialDraft)); const handleInputChange = (e: React.ChangeEvent) => { const newValue = e.target.value; - const byteLength = new Blob([newValue]).size; + const byteLength = calculateBytes(newValue); if (byteLength <= maxBytes) { setLocalDraft(newValue); - debouncedSetMessageDraft(newValue); setMessageBytes(byteLength); + setDraft(to, newValue); } }; + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!localDraft.trim()) return; + // Reset bytes *before* sending (consider if onSend failure needs different handling) + setMessageBytes(0); + + startTransition(() => { + onSend(localDraft.trim()); + setLocalDraft(""); + clearDraft(to); + }); + }; + return ( - { - // prevent user from sending blank/empty message - if (localDraft === "") return; - const message = formData.get("messageInput") as string; - startTransition(() => { - if (!isQueueingMessages) { - sendText(message); - setLocalDraft(""); - setMessageDraft(""); - setMessageBytes(0); - } - - }); - }} - > - + + - + + {messageBytes}/{maxBytes} - + ); -}; +}; \ No newline at end of file diff --git a/src/components/PageComponents/Messages/MessageItem.tsx b/src/components/PageComponents/Messages/MessageItem.tsx index 384aaefc..ff696007 100644 --- a/src/components/PageComponents/Messages/MessageItem.tsx +++ b/src/components/PageComponents/Messages/MessageItem.tsx @@ -5,122 +5,138 @@ import { TooltipProvider, TooltipTrigger, } from "@components/UI/Tooltip.tsx"; -import { useDeviceStore } from "@core/stores/deviceStore.ts"; +import { useDevice } from "@core/stores/deviceStore.ts"; import { cn } from "@core/utils/cn.ts"; import { Avatar } from "@components/UI/Avatar.tsx"; import { AlertCircle, CheckCircle2, CircleEllipsis } from "lucide-react"; import type { LucideIcon } from "lucide-react"; import { ReactNode, useMemo } from "react"; -import { Message, MessageState } from "@core/services/types.ts"; +import { MessageState, useMessageStore } from "@core/stores/messageStore/index.ts"; +import { Protobuf, Types } from "@meshtastic/js"; +import { Message } from "@core/stores/messageStore/types.ts"; +// import { MessageActionsMenu } from "@components/PageComponents/Messages/MessageActionsMenu.tsx"; // Uncomment if needed later -interface MessageProps { - lastMsgSameUser: boolean; - message: Message; -} - -interface MessageStatus { - state: MessageState; +interface MessageStatusInfo { displayText: string; icon: LucideIcon; + ariaLabel: string; + iconClassName?: string; } -const MESSAGE_STATUS: Record = { - ack: { state: "ack", displayText: "Message delivered", icon: CheckCircle2 }, - waiting: { state: "waiting", displayText: "Waiting for delivery", icon: CircleEllipsis }, - failed: { state: "failed", displayText: "Delivery failed", icon: AlertCircle }, +const MESSAGE_STATUS_MAP: Record = { + [MessageState.Ack]: { displayText: "Message delivered", icon: CheckCircle2, ariaLabel: "Message delivered", iconClassName: "text-green-500" }, + [MessageState.Waiting]: { displayText: "Waiting for delivery", icon: CircleEllipsis, ariaLabel: "Sending message", iconClassName: "text-slate-400" }, + [MessageState.Failed]: { displayText: "Delivery failed", icon: AlertCircle, ariaLabel: "Message delivery failed", iconClassName: "text-red-500 dark:text-red-400" }, }; -const getMessageStatus = (state: MessageState): MessageStatus => - MESSAGE_STATUS[state] || { state: "failed", displayText: "Unknown error", icon: AlertCircle }; +const UNKNOWN_STATUS: MessageStatusInfo = { displayText: "Unknown state", icon: AlertCircle, ariaLabel: "Message status unknown", iconClassName: "text-red-500 dark:text-red-400" }; -const StatusTooltip = ({ status, children }: { status: MessageStatus; children: ReactNode }) => ( - +const getMessageStatusInfo = (state: MessageState): MessageStatusInfo => + MESSAGE_STATUS_MAP[state] ?? UNKNOWN_STATUS; + +const StatusTooltip = ({ statusInfo, children }: { statusInfo: MessageStatusInfo; children: ReactNode }) => ( + {children} - - {status.displayText} + + {statusInfo.displayText} ); -const StatusIcon = ({ status, className, ...otherProps }: { status: MessageStatus; className?: string }) => { - const isFailed = status.state === "failed"; - const iconClass = cn("text-slate-500 dark:text-slate-400 w-4 h-4 shrink-0", className); - const Icon = status.icon; +interface MessageItemProps { + message: Message; +} - return ( - - - - ); -}; +export const MessageItem = ({ message }: MessageItemProps) => { + const { getNode } = useDevice(); + const { getMyNodeNum } = useMessageStore() -const getMessageTextStyles = (status: MessageStatus) => { - const isAcknowledged = status.state === "ack"; - const isFailed = status.state === "failed"; + const messageUser: Protobuf.Mesh.NodeInfo | null | undefined = useMemo(() => { + return message.from != null ? getNode(message.from) : null; + }, [getNode, message.from]); - return cn( - "break-words overflow-hidden", - isAcknowledged ? "text-slate-900 dark:text-white" : "text-slate-900 dark:text-slate-400", - isFailed && "text-red-500 dark:text-red-500", - ); -}; -const TimeDisplay = ({ date, className }: { date: Date; className?: string }) => ( - - {date.toLocaleDateString()} - - {date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })} - - -); + const myNodeNum = useMemo(() => getMyNodeNum(), [getMyNodeNum]); + const { displayName, shortName } = useMemo(() => { + const userIdHex = message.from.toString(16).toUpperCase().padStart(2, '0'); + const last4 = userIdHex.slice(-4); + const fallbackName = `Meshtastic ${last4}` + const longName = messageUser?.user?.longName; + const derivedShortName = messageUser?.user?.shortName || fallbackName; + const derivedDisplayName = longName || derivedShortName; + return { displayName: derivedDisplayName, shortName: derivedShortName }; + }, [messageUser, message.from]); -export const MessageItem = ({ lastMsgSameUser, message }: MessageProps) => { - const { getDevices } = useDeviceStore(); + const messageStatusInfo = getMessageStatusInfo(message.state); + const StatusIconComponent = messageStatusInfo.icon; - const isDeviceUser = useMemo( - () => - getDevices() - .map((device) => device.nodes.get(device.hardware.myNodeNum)?.num) - .includes(message.from), - [getDevices, message.from], - ); + const messageDate = useMemo(() => message.date ? new Date(message.date) : null, [message.date]); + const locale = 'en-US'; // TODO: Make dynamic via props or context + + const formattedTime = useMemo(() => + messageDate?.toLocaleTimeString(locale, { hour: 'numeric', minute: '2-digit', hour12: true }) ?? '', + [messageDate, locale]); - const messageUser = message?.from - ? getDevices().find((device) => device.nodes.has(message.from))?.nodes.get(message.from) - : null; + const fullDateTime = useMemo(() => + messageDate?.toLocaleString(locale, { dateStyle: 'medium', timeStyle: 'short' }) ?? '', + [messageDate, locale]); + + const isSender = myNodeNum !== undefined && message.from === myNodeNum; + const isOnPrimaryChannel = message.channel === Types.ChannelNumber.Primary; // Use the enum + const shouldShowStatusIcon = isSender && isOnPrimaryChannel; + + + const messageItemWrapperClass = cn( + "group w-full py-2 relative list-none", + "rounded-md", + "hover:bg-slate-300/15 dark:hover:bg-slate-600/20", + "transition-colors duration-100 ease-in-out", + ); + const dateTextStyle = "text-xs text-slate-500 dark:text-slate-400"; - const messageStatus = getMessageStatus(message.state); - const messageTextClass = getMessageTextStyles(messageStatus); return ( - - - - {!lastMsgSameUser && ( - - - - - {messageUser?.user?.longName} + + + + + + + + {displayName} + + {messageDate && ( + + {formattedTime} + {fullDateTime} + + )} + {shouldShowStatusIcon && ( + + + - + + )} + + + {message?.message && ( + + {message.message} )} - - - {message.message} - - - + {/* Actions Menu Placeholder */} + {/* + console.log("Reply")} /> + */} + ); -}; +}; \ No newline at end of file diff --git a/src/components/PageComponents/Messages/TraceRoute.test.tsx b/src/components/PageComponents/Messages/TraceRoute.test.tsx index 977ef315..39443acb 100644 --- a/src/components/PageComponents/Messages/TraceRoute.test.tsx +++ b/src/components/PageComponents/Messages/TraceRoute.test.tsx @@ -1,30 +1,33 @@ -import { describe, it, expect, vi, beforeEach, Mock } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen } from "@testing-library/react"; import { TraceRoute } from "@components/PageComponents/Messages/TraceRoute.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; +import type { Protobuf } from "@meshtastic/core"; vi.mock("@core/stores/deviceStore"); describe("TraceRoute", () => { - const mockNodes = new Map([ + const mockNodes = new Map([ [ 1, - { num: 1, user: { longName: "Node A" } }, + { num: 1, user: { longName: "Node A" } } as Protobuf.Mesh.NodeInfo, ], [ 2, - { num: 2, user: { longName: "Node B" } }, + { num: 2, user: { longName: "Node B" } } as Protobuf.Mesh.NodeInfo, ], [ 3, - { num: 3, user: { longName: "Node C" } }, + { num: 3, user: { longName: "Node C" } } as Protobuf.Mesh.NodeInfo, ], ]); beforeEach(() => { vi.resetAllMocks(); - (useDevice as Mock).mockReturnValue({ - nodes: mockNodes, + vi.mocked(useDevice).mockReturnValue({ + getNode: (nodeNum: number): Protobuf.Mesh.NodeInfo | undefined => { + return mockNodes.get(nodeNum); + }, }); }); @@ -38,17 +41,16 @@ describe("TraceRoute", () => { /> ); - expect(screen.getByText("Route to destination:")).toBeInTheDocument(); + expect(screen.getAllByText("Source Node")).toHaveLength(1); expect(screen.getByText("Destination Node")).toBeInTheDocument(); expect(screen.getByText("Node A")).toBeInTheDocument(); expect(screen.getByText("Node B")).toBeInTheDocument(); - expect(screen.getAllByText(/↓/)).toHaveLength(3); // startNode + 2 hops + expect(screen.getAllByText(/↓/)).toHaveLength(3); expect(screen.getByText("↓ 10dB")).toBeInTheDocument(); expect(screen.getByText("↓ 20dB")).toBeInTheDocument(); expect(screen.getByText("↓ 30dB")).toBeInTheDocument(); - expect(screen.getByText("Source Node")).toBeInTheDocument(); }); it("renders the route back when provided", () => { @@ -64,9 +66,20 @@ describe("TraceRoute", () => { ); expect(screen.getByText("Route back:")).toBeInTheDocument(); + + expect(screen.getAllByText("Source Node")).toHaveLength(2); + + expect(screen.getAllByText("Destination Node")).toHaveLength(2); + expect(screen.getByText("Node C")).toBeInTheDocument(); + expect(screen.getByText("Node A")).toBeInTheDocument(); + expect(screen.getByText("↓ 35dB")).toBeInTheDocument(); expect(screen.getByText("↓ 45dB")).toBeInTheDocument(); + + expect(screen.getByText("↓ 15dB")).toBeInTheDocument(); + expect(screen.getByText("↓ 25dB")).toBeInTheDocument(); + }); it("renders '??' for missing SNR values", () => { @@ -78,18 +91,22 @@ describe("TraceRoute", () => { /> ); - expect(screen.getAllByText("↓ ??dB").length).toBeGreaterThan(0); + expect(screen.getByText("Node A")).toBeInTheDocument(); + expect(screen.getAllByText("↓ ??dB")).toHaveLength(2); }); it("renders hop hex if node is not found", () => { render( ); - expect(screen.getByText(/^!63$/)).toBeInTheDocument(); // 99 in hex + expect(screen.getByText(/^!63$/)).toBeInTheDocument(); + expect(screen.getByText("↓ 5dB")).toBeInTheDocument(); + expect(screen.getByText("↓ 15dB")).toBeInTheDocument(); }); -}); +}); \ No newline at end of file diff --git a/src/components/PageComponents/Messages/TraceRoute.tsx b/src/components/PageComponents/Messages/TraceRoute.tsx index 13f8f08f..e67baacc 100644 --- a/src/components/PageComponents/Messages/TraceRoute.tsx +++ b/src/components/PageComponents/Messages/TraceRoute.tsx @@ -20,16 +20,16 @@ interface RoutePathProps { } const RoutePath = ({ title, startNode, endNode, path, snr }: RoutePathProps) => { - const { nodes } = useDevice(); + const { getNode } = useDevice(); return ( - + {title} {startNode?.user?.longName} ↓ {snr?.[0] ?? "??"}dB {path.map((hop, i) => ( - - {nodes.get(hop)?.user?.longName ?? `!${numberToHexUnpadded(hop)}`} + + {getNode(hop)?.user?.longName ?? `!${numberToHexUnpadded(hop)}`} ↓ {snr?.[i + 1] ?? "??"}dB ))} diff --git a/src/components/PageLayout.tsx b/src/components/PageLayout.tsx index 132e7bc4..40ae524e 100644 --- a/src/components/PageLayout.tsx +++ b/src/components/PageLayout.tsx @@ -1,77 +1,126 @@ +import React from 'react'; import { cn } from "@core/utils/cn.ts"; -import { AlignLeftIcon, type LucideIcon } from "lucide-react"; +import { type LucideIcon } from "lucide-react"; import Footer from "@components/UI/Footer.tsx"; import { Spinner } from "@components/UI/Spinner.tsx"; import { ErrorBoundary } from "react-error-boundary"; import { ErrorPage } from "@components/UI/ErrorPage.tsx"; +export interface ActionItem { + key: string; + icon: LucideIcon; + iconClasses?: string; + onClick: () => void; + disabled?: boolean; + isLoading?: boolean; + ariaLabel?: string; +} export interface PageLayoutProps { label: string; - noPadding?: boolean; + actions?: ActionItem[]; children: React.ReactNode; - className?: string; - actions?: { - icon: LucideIcon; - iconClasses?: string; - onClick: () => void; - disabled?: boolean; - isLoading?: boolean; - }[]; + leftBar?: React.ReactNode; + rightBar?: React.ReactNode; + noPadding?: boolean; + leftBarClassName?: string; + rightBarClassName?: string; + topBarClassName?: string; + contentClassName?: string; } export const PageLayout = ({ label, - noPadding, actions, - className, children, + leftBar, + rightBar, + noPadding, + leftBarClassName, + rightBarClassName, + topBarClassName, + contentClassName }: PageLayoutProps) => { return ( - - - + {/* Left Sidebar */} + {leftBar && ( + + )} + + + {/* Header */} + - - - - - {label} - + {/* Header Content */} + + + {label} + + {actions?.map((action) => ( - {action?.isLoading ? : ( - - )} + + {action.isLoading ? ( + + ) : ( + + )} + ))} - - - - {children} + + + + {children} + + + {/* Right Sidebar */} + {rightBar && ( + + )} ); -}; +}; \ No newline at end of file diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 2ce7bd9c..caa541f4 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,148 +1,241 @@ +import React from "react"; import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx"; -import { SidebarButton } from "@components/UI/Sidebar/sidebarButton.tsx"; import { Subtle } from "@components/UI/Typography/Subtle.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; import type { Page } from "@core/stores/deviceStore.ts"; import { Spinner } from "@components/UI/Spinner.tsx"; +import { Avatar } from "@components/UI/Avatar.tsx"; import { - BatteryMediumIcon, + CircleChevronLeft, CpuIcon, - EditIcon, LayersIcon, type LucideIcon, MapIcon, MessageSquareIcon, + PenLine, + SearchIcon, SettingsIcon, - SidebarCloseIcon, - SidebarOpenIcon, UsersIcon, ZapIcon, } from "lucide-react"; -import { useState } from "react"; +import { cn } from "@core/utils/cn.ts"; +import { useSidebar } from "@core/stores/sidebarStore.tsx"; +import ThemeSwitcher from "@components/ThemeSwitcher.tsx"; +import { useAppStore } from "@core/stores/appStore.ts"; +import BatteryStatus from "@components/BatteryStatus.tsx"; +import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.tsx"; export interface SidebarProps { children?: React.ReactNode; } +interface NavLink { + name: string; + icon: LucideIcon; + page: Page; +} + +const CollapseToggleButton = () => { + const { isCollapsed, toggleSidebar } = useSidebar(); + const buttonLabel = isCollapsed ? "Open sidebar" : "Close sidebar"; + + return ( + + + + ); +} + export const Sidebar = ({ children }: SidebarProps) => { - const { hardware, nodes, metadata } = useDevice(); - const myNode = nodes.get(hardware.myNodeNum); + const { hardware, getNode, getNodesLength, metadata, activePage, setActivePage, setDialogOpen } = useDevice(); + const { setCommandPaletteOpen } = useAppStore(); + const myNode = getNode(hardware.myNodeNum); + const { isCollapsed } = useSidebar(); const myMetadata = metadata.get(0); - const { activePage, setActivePage, setDialogOpen } = useDevice(); - const [showSidebar, setShowSidebar] = useState(true); - - interface NavLink { - name: string; - icon: LucideIcon; - page: Page; - } const pages: NavLink[] = [ + { name: "Messages", icon: MessageSquareIcon, page: "messages" }, + { name: "Map", icon: MapIcon, page: "map" }, + { name: "Config", icon: SettingsIcon, page: "config" }, + { name: "Channels", icon: LayersIcon, page: "channels" }, { - name: "Messages", - icon: MessageSquareIcon, - page: "messages", - }, - { - name: "Map", - icon: MapIcon, - page: "map", - }, - { - name: "Config", - icon: SettingsIcon, - page: "config", - }, - { - name: "Channels", - icon: LayersIcon, - page: "channels", - }, - { - name: `Nodes (${nodes.size - 1})`, + name: `Nodes (${Math.max(getNodesLength() - 1, 0)})`, icon: UsersIcon, page: "nodes", }, ]; - return showSidebar - ? ( - + return ( + + + + + + + Meshtastic + + + + + {pages.map((link) => ( + { + if (myNode !== undefined) { + setActivePage(link.page); + } + }} + active={link.page === activePage} + disabled={myNode === undefined} + /> + ))} + + + + {children} + + + {myNode === undefined ? ( - + - Loading device info... + + Loading... + ) : ( <> - - - - {myNode.user?.shortName ?? "UNK"} - - {myNode.user?.longName ?? "UNK"} + + + + {myNode.user?.longName} + + + + + + + + + + {myNode.deviceMetrics?.voltage?.toPrecision(3) ?? "UNK"} volts + + + v{myMetadata?.firmwareVersion ?? "UNK"} + + + setDialogOpen("deviceName", true)} > - + - setShowSidebar(false)}> - + + setCommandPaletteOpen(true)} + > + - - - - - {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} - - ) - : ( - - setShowSidebar(true)}> - - - ); -}; + + ); +}; \ No newline at end of file diff --git a/src/components/ThemeSwitcher.tsx b/src/components/ThemeSwitcher.tsx index ee075793..979c974b 100644 --- a/src/components/ThemeSwitcher.tsx +++ b/src/components/ThemeSwitcher.tsx @@ -12,9 +12,9 @@ export default function ThemeSwitcher({ const { theme, preference, setPreference } = useTheme(); const themeIcons = { - light: , - dark: , - system: , + light: , + dark: , + system: , }; const toggleTheme = () => { @@ -30,15 +30,15 @@ export default function ThemeSwitcher({ {firstCharOfPreference.toLocaleUpperCase() + (restOfPreference ?? []).join("")} diff --git a/src/components/UI/Avatar.tsx b/src/components/UI/Avatar.tsx index 484aa2c3..e6996ea1 100644 --- a/src/components/UI/Avatar.tsx +++ b/src/components/UI/Avatar.tsx @@ -1,6 +1,5 @@ import { cn } from "@core/utils/cn.ts"; import { LockKeyholeOpenIcon } from 'lucide-react'; -import type React from "react"; type RGBColor = { r: number; @@ -10,13 +9,12 @@ type RGBColor = { }; interface AvatarProps { - text: string; + text: string | number; size?: "sm" | "lg"; className?: string; showError?: boolean; } -// biome-ignore lint/complexity/noStaticOnlyClass: stop being annoying Biome class ColorUtils { static hexToRgb(hex: number): RGBColor { return { @@ -42,46 +40,46 @@ class ColorUtils { } } -export const Avatar: React.FC = ({ +const getColorFromText = (text: string): RGBColor => { + if (!text) { + return { r: 0, g: 0, b: 0, a: 255 }; + } + let hash = 0; + for (let i = 0; i < text?.length; i++) { + hash = text.charCodeAt(i) + ((hash << 5) - hash); + hash |= 0; + } + + return { + r: (hash & 0xff0000) >> 16, + g: (hash & 0x00ff00) >> 8, + b: hash & 0x0000ff, + a: 255, + }; +}; + +export const Avatar = ({ text, size = "sm", showError = false, className, -}) => { +}: AvatarProps) => { const sizes = { - sm: "size-11 text-xs", + sm: "size-10 text-xs font-light", lg: "size-16 text-lg", }; - // Pick a color based on the text provided to function - const getColorFromText = (text: string): RGBColor => { - let hash = 0; - for (let i = 0; i < text.length; i++) { - hash = text.charCodeAt(i) + ((hash << 5) - hash); - } - - return { - r: (hash & 0xff0000) >> 16, - g: (hash & 0x00ff00) >> 8, - b: hash & 0x0000ff, - a: 255, - }; - }; - - const bgColor = getColorFromText(text ?? "UNK"); + const safeText = text?.toString().toUpperCase(); + const bgColor = getColorFromText(safeText); const isLight = ColorUtils.isLight(bgColor); const textColor = isLight ? "#000000" : "#FFFFFF"; - const initials = text?.toUpperCase().slice(0, 4) ?? "UNK"; + const initials = safeText?.slice(0, 4) ?? "UNK"; return ( = ({ color: textColor, }} > - {showError ? : null} - {initials} + {showError ? ( + + ) : null} + + {initials} + ); -}; +}; \ No newline at end of file diff --git a/src/components/UI/Button.tsx b/src/components/UI/Button.tsx index bb7e68e1..4aa95488 100644 --- a/src/components/UI/Button.tsx +++ b/src/components/UI/Button.tsx @@ -1,21 +1,20 @@ import { cva, type VariantProps } from "class-variance-authority"; import * as React from "react"; - import { cn } from "@core/utils/cn.ts"; const buttonVariants = cva( - "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus:outline-hidden focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:opacity-50 dark:focus:ring-slate-400 disabled:pointer-events-none dark:focus:ring-offset-slate-900 cursor-pointer", + "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:opacity-50 dark:focus:ring-slate-400 disabled:cursor-not-allowed dark:focus:ring-offset-slate-900 cursor-pointer", { variants: { variant: { default: - "bg-slate-900 text-white dark:bg-slate-900 hover:dark:bg-slate-700 dark:text-slate-100 hover:bg-slate-800 ", + "bg-slate-900 text-white dark:bg-slate-50 hover:dark:bg-slate-200 dark:text-slate-900 hover:bg-slate-500", destructive: "bg-red-500 text-white hover:bg-red-600 dark:hover:bg-red-600", success: "bg-green-500 text-white hover:bg-green-600 dark:hover:bg-green-600", outline: - "bg-transparent border border-slate-400 hover:bg-slate-100 dark:border-slate-400 dark:text-slate-500", + "bg-transparent border border-slate-400 hover:text-slate-400 dark:hover:text-slate-300 dark:border-slate-400 dark:text-slate-100 ", subtle: "bg-slate-100 text-slate-700 hover:bg-slate-200 dark:bg-slate-500 dark:text-white dark:hover:bg-slate-400", ghost: @@ -27,6 +26,7 @@ const buttonVariants = cva( default: "h-10 py-2 px-4", sm: "h-9 px-2 rounded-md", lg: "h-11 px-8 rounded-md", + icon: "h-10 w-10", }, }, defaultVariants: { @@ -39,26 +39,50 @@ const buttonVariants = cva( export type ButtonVariant = VariantProps["variant"]; export interface ButtonProps - extends - React.ButtonHTMLAttributes, - VariantProps { } + extends React.ButtonHTMLAttributes, + VariantProps { + icon?: React.ReactNode; + iconAlignment?: "left" | "right"; +} const Button = React.forwardRef( - ({ className, variant, size, disabled, ...props }, ref) => { + ( + { + className, + variant, + size, + disabled, + icon, + iconAlignment = "left", + children, + ...props + }, + ref, + ) => { return ( + > + {icon && iconAlignment === "left" && ( + {icon} + )} + {children} + + {icon && iconAlignment === "right" && ( + {icon} + )} + ); }, ); Button.displayName = "Button"; -export { Button, buttonVariants }; +export { Button, buttonVariants }; \ No newline at end of file diff --git a/src/components/UI/Checkbox/index.tsx b/src/components/UI/Checkbox/index.tsx index 59240c79..ac6ff441 100644 --- a/src/components/UI/Checkbox/index.tsx +++ b/src/components/UI/Checkbox/index.tsx @@ -1,4 +1,4 @@ -import { useState, useId } from "react"; +import { useState, useEffect, useId } from "react"; import { Check } from "lucide-react"; import { Label } from "@components/UI/Label.tsx"; import { cn } from "@core/utils/cn.ts"; @@ -32,6 +32,11 @@ export function Checkbox({ const [isChecked, setIsChecked] = useState(checked || false); + // Make sure setIsChecked state updates with checked + useEffect(() => { + setIsChecked(checked || false); + }, [checked]); + const handleToggle = () => { if (disabled) return; @@ -66,7 +71,7 @@ export function Checkbox({ > {isChecked && ( - + )} diff --git a/src/components/UI/Command.tsx b/src/components/UI/Command.tsx index d230d326..d6673525 100644 --- a/src/components/UI/Command.tsx +++ b/src/components/UI/Command.tsx @@ -41,7 +41,7 @@ const CommandInput = React.forwardRef< className="flex items-center border-b border-b-slate-100 px-4 dark:border-b-slate-700" cmdk-input-wrapper="" > - + (({ className, ...props }, ref) => ( )); diff --git a/src/components/UI/Dialog.tsx b/src/components/UI/Dialog.tsx index 442af95e..256e747b 100644 --- a/src/components/UI/Dialog.tsx +++ b/src/components/UI/Dialog.tsx @@ -44,7 +44,7 @@ const DialogContent = React.forwardRef< & { className?: string }) => ( (({ className, ...props }, ref) => ( )); @@ -118,7 +119,7 @@ const DialogDescription = React.forwardRef< >(({ className, ...props }, ref) => ( )); diff --git a/src/components/UI/Footer.tsx b/src/components/UI/Footer.tsx index 775b68d7..9eb56dec 100644 --- a/src/components/UI/Footer.tsx +++ b/src/components/UI/Footer.tsx @@ -7,7 +7,7 @@ type FooterProps = { const Footer = ({ className, ...props }: FooterProps) => { return (
{import.meta.env.VITE_COMMIT_HASH}
+
Device Metrics:
{description}
- {validationText} -
+ {validationText} +
+ 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 + +
{title}
{startNode?.user?.longName}
↓ {snr?.[0] ?? "??"}dB
{nodes.get(hop)?.user?.longName ?? `!${numberToHexUnpadded(hop)}`}
{getNode(hop)?.user?.longName ?? `!${numberToHexUnpadded(hop)}`}
↓ {snr?.[i + 1] ?? "??"}dB
+ {myNode.user?.longName} +
{initials}
+ {initials} +