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