Browse Source

chore: lint/format all files (#604)

* chore: lint/format all files

* Fix config sidebar button state (#602)

* chore: Update deno.lock version and add Radix UI slider component (#601)

* fix: improve how table addresses even/odd rows

---------

Co-authored-by: philon- <[email protected]>
Co-authored-by: Kamil Dzieniszewski <[email protected]>
pull/605/head
Dan Ditomaso 1 year ago
committed by GitHub
parent
commit
480ca46a95
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 13
      deno.json
  2. 1648
      deno.lock
  3. 76
      package.json
  4. 42
      src/App.tsx
  5. 19
      src/__mocks__/components/UI/Button.tsx
  6. 23
      src/__mocks__/components/UI/Checkbox.tsx
  7. 40
      src/__mocks__/components/UI/Dialog/Dialog.tsx
  8. 19
      src/__mocks__/components/UI/Label.tsx
  9. 12
      src/__mocks__/components/UI/Link.tsx
  10. 46
      src/components/BatteryStatus.tsx
  11. 34
      src/components/CommandPalette/index.tsx
  12. 67
      src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.test.tsx
  13. 1
      src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx
  14. 18
      src/components/Dialog/DeviceNameDialog.tsx
  15. 1
      src/components/Dialog/DialogManager.tsx
  16. 2
      src/components/Dialog/ImportDialog.tsx
  17. 5
      src/components/Dialog/LocationResponseDialog.tsx
  18. 14
      src/components/Dialog/NewDeviceDialog.tsx
  19. 33
      src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.test.tsx
  20. 28
      src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx
  21. 5
      src/components/Dialog/NodeOptionsDialog.tsx
  22. 2
      src/components/Dialog/PkiRegenerateDialog.tsx
  23. 24
      src/components/Dialog/QRDialog.tsx
  24. 56
      src/components/Dialog/RebootOTADialog.test.tsx
  25. 14
      src/components/Dialog/RebootOTADialog.tsx
  26. 30
      src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.test.tsx
  27. 19
      src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx
  28. 14
      src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.test.ts
  29. 9
      src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.ts
  30. 4
      src/components/Dialog/TracerouteResponseDialog.tsx
  31. 73
      src/components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.test.tsx
  32. 41
      src/components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx
  33. 98
      src/components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.test.tsx
  34. 6
      src/components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts
  35. 5
      src/components/Form/DynamicForm.tsx
  36. 28
      src/components/Form/FormInput.tsx
  37. 2
      src/components/Form/FormMultiSelect.tsx
  38. 2
      src/components/Form/FormPasswordGenerator.tsx
  39. 5
      src/components/Form/FormSelect.tsx
  40. 4
      src/components/Form/FormWrapper.tsx
  41. 13
      src/components/PageComponents/Channel.tsx
  42. 66
      src/components/PageComponents/Config/Device/Device.test.tsx
  43. 12
      src/components/PageComponents/Config/Device/index.tsx
  44. 190
      src/components/PageComponents/Config/Network/Network.test.tsx
  45. 14
      src/components/PageComponents/Config/Network/index.tsx
  46. 2
      src/components/PageComponents/Config/Position.tsx
  47. 14
      src/components/PageComponents/Config/Security/Security.tsx
  48. 10
      src/components/PageComponents/Connect/BLE.tsx
  49. 24
      src/components/PageComponents/Connect/HTTP.test.tsx
  50. 39
      src/components/PageComponents/Connect/HTTP.tsx
  51. 11
      src/components/PageComponents/Connect/Serial.tsx
  52. 19
      src/components/PageComponents/Map/NodeDetail.tsx
  53. 20
      src/components/PageComponents/Messages/ChannelChat.tsx
  54. 20
      src/components/PageComponents/Messages/MessageActionsMenu.tsx
  55. 168
      src/components/PageComponents/Messages/MessageInput.test.tsx
  56. 11
      src/components/PageComponents/Messages/MessageInput.tsx
  57. 99
      src/components/PageComponents/Messages/MessageItem.tsx
  58. 25
      src/components/PageComponents/Messages/TraceRoute.test.tsx
  59. 13
      src/components/PageComponents/Messages/TraceRoute.tsx
  60. 18
      src/components/PageLayout.tsx
  61. 2
      src/components/ThemeSwitcher.tsx
  62. 22
      src/components/UI/Avatar.tsx
  63. 9
      src/components/UI/Button.tsx
  64. 111
      src/components/UI/Checkbox/Checkbox.test.tsx
  65. 11
      src/components/UI/Dialog.tsx
  66. 1
      src/components/UI/ErrorPage.tsx
  67. 11
      src/components/UI/Footer.tsx
  68. 168
      src/components/UI/Generator.tsx
  69. 55
      src/components/UI/Input.tsx
  70. 20
      src/components/UI/Sidebar/SidebarButton.tsx
  71. 20
      src/components/UI/Sidebar/sidebarButton.tsx
  72. 207
      src/components/generic/Table/index.test.tsx
  73. 178
      src/components/generic/Table/index.tsx
  74. 13
      src/core/dto/NodeNumToNodeInfoDTO.ts
  75. 24
      src/core/dto/PacketToMessageDTO.ts
  76. 12
      src/core/hooks/useCopyToClipboard.ts
  77. 13
      src/core/hooks/useKeyBackupReminder.tsx
  78. 78
      src/core/hooks/useLocalStorage.test.ts
  79. 27
      src/core/hooks/useLocalStorage.ts
  80. 28
      src/core/hooks/usePasswordVisibilityToggle.test.ts
  81. 14
      src/core/hooks/usePasswordVisibilityToggle.ts
  82. 11
      src/core/hooks/usePinnedItems.test.ts
  83. 9
      src/core/hooks/usePinnedItems.ts
  84. 108
      src/core/hooks/useToast.test.tsx
  85. 154
      src/core/stores/deviceStore.ts
  86. 91
      src/core/stores/messageStore/index.ts
  87. 308
      src/core/stores/messageStore/messageStore.test.ts
  88. 20
      src/core/stores/messageStore/types.ts
  89. 18
      src/core/stores/sidebarStore.tsx
  90. 25
      src/core/stores/storage/indexDB.ts
  91. 17
      src/core/subscriptions.ts
  92. 4
      src/core/utils/eventBus.test.ts
  93. 18
      src/core/utils/eventBus.ts
  94. 1
      src/core/utils/github.ts
  95. 5
      src/core/utils/ip.ts
  96. 23
      src/core/utils/string.ts
  97. 4
      src/index.css
  98. 9
      src/pages/Channels.tsx
  99. 129
      src/pages/Messages.test.tsx
  100. 240
      src/pages/Messages.tsx

13
deno.json

@ -28,6 +28,19 @@
], ],
"strictPropertyInitialization": false "strictPropertyInitialization": false
}, },
"fmt": {
"exclude": [
"*.test.ts",
"*.test.tsx"
]
},
"lint": {
"exclude": [
"*.test.ts",
"*.test.tsx"
],
"report": "pretty"
},
"unstable": [ "unstable": [
"sloppy-imports" "sloppy-imports"
] ]

1648
deno.lock

File diff suppressed because it is too large

76
package.json

@ -40,25 +40,25 @@
"@meshtastic/transport-web-bluetooth": "npm:@jsr/meshtastic__transport-web-bluetooth", "@meshtastic/transport-web-bluetooth": "npm:@jsr/meshtastic__transport-web-bluetooth",
"@meshtastic/transport-web-serial": "npm:@jsr/meshtastic__transport-web-serial", "@meshtastic/transport-web-serial": "npm:@jsr/meshtastic__transport-web-serial",
"@bufbuild/protobuf": "^2.2.5", "@bufbuild/protobuf": "^2.2.5",
"@noble/curves": "^1.8.1", "@noble/curves": "^1.9.0",
"@radix-ui/react-accordion": "^1.2.3", "@radix-ui/react-accordion": "^1.2.8",
"@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-checkbox": "^1.2.3",
"@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dialog": "^1.1.11",
"@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-dropdown-menu": "^2.1.12",
"@radix-ui/react-label": "^2.1.2", "@radix-ui/react-label": "^2.1.4",
"@radix-ui/react-menubar": "^1.1.6", "@radix-ui/react-menubar": "^1.1.12",
"@radix-ui/react-popover": "^1.1.6", "@radix-ui/react-popover": "^1.1.11",
"@radix-ui/react-scroll-area": "^1.2.3", "@radix-ui/react-scroll-area": "^1.2.6",
"@radix-ui/react-select": "^2.1.6", "@radix-ui/react-select": "^2.2.2",
"@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-separator": "^1.1.4",
"@radix-ui/react-slider": "^1.3.2", "@radix-ui/react-slider": "^1.3.2",
"@radix-ui/react-switch": "^1.1.3", "@radix-ui/react-switch": "^1.2.2",
"@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-tabs": "^1.1.9",
"@radix-ui/react-toast": "^1.2.6", "@radix-ui/react-toast": "^1.2.11",
"@radix-ui/react-tooltip": "^1.1.8", "@radix-ui/react-tooltip": "^1.2.4",
"@turf/turf": "^7.2.0", "@turf/turf": "^7.2.0",
"base64-js": "^1.5.1", "base64-js": "^1.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
@ -66,46 +66,46 @@
"idb-keyval": "^6.2.1", "idb-keyval": "^6.2.1",
"immer": "^10.1.1", "immer": "^10.1.1",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"lucide-react": "^0.486.0", "lucide-react": "^0.507.0",
"maplibre-gl": "5.3.0", "maplibre-gl": "5.4.0",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-error-boundary": "^5.0.0", "react-error-boundary": "^6.0.0",
"react-hook-form": "^7.55.0", "react-hook-form": "^7.56.2",
"react-map-gl": "8.0.2", "react-map-gl": "8.0.4",
"react-qrcode-logo": "^3.0.0", "react-qrcode-logo": "^3.0.0",
"rfc4648": "^1.5.4", "rfc4648": "^1.5.4",
"vite-plugin-node-polyfills": "^0.23.0", "vite-plugin-node-polyfills": "^0.23.0",
"zod": "^3.24.2", "zod": "^3.24.3",
"zustand": "5.0.3" "zustand": "5.0.4"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.1.0", "@tailwindcss/postcss": "^4.1.5",
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0", "@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@types/chrome": "^0.0.313", "@types/chrome": "^0.0.318",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"@types/node": "^22.13.17", "@types/node": "^22.15.3",
"@types/react": "^19.0.12", "@types/react": "^19.1.2",
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.1.3",
"@types/serviceworker": "^0.0.127", "@types/serviceworker": "^0.0.133",
"@types/w3c-web-serial": "^1.0.8", "@types/w3c-web-serial": "^1.0.8",
"@types/web-bluetooth": "^0.0.21", "@types/web-bluetooth": "^0.0.21",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.4.1",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"gzipper": "^8.2.1", "gzipper": "^8.2.1",
"happy-dom": "^17.4.4", "happy-dom": "^17.4.6",
"postcss": "^8.5.3", "postcss": "^8.5.3",
"simple-git-hooks": "^2.12.1", "simple-git-hooks": "^2.13.0",
"tailwind-merge": "^3.1.0", "tailwind-merge": "^3.2.0",
"tailwindcss": "^4.1.0", "tailwindcss": "^4.1.5",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"tar": "^7.4.3", "tar": "^7.4.3",
"testing-library": "^0.0.2", "testing-library": "^0.0.2",
"typescript": "^5.8.2", "typescript": "^5.8.3",
"vite": "^6.2.4", "vite": "^6.3.4",
"vitest": "^3.1.1", "vitest": "^3.1.2",
"vite-plugin-pwa": "^1.0.0" "vite-plugin-pwa": "^1.0.0"
} }
} }

42
src/App.tsx

@ -16,7 +16,6 @@ import { CommandPalette } from "@components/CommandPalette/index.tsx";
import { SidebarProvider } from "@core/stores/sidebarStore.tsx"; import { SidebarProvider } from "@core/stores/sidebarStore.tsx";
import { useTheme } from "@core/hooks/useTheme.ts"; import { useTheme } from "@core/hooks/useTheme.ts";
export const App = (): JSX.Element => { export const App = (): JSX.Element => {
const { getDevice } = useDeviceStore(); const { getDevice } = useDeviceStore();
const { selectedDevice, setConnectDialogOpen, connectDialogOpen } = const { selectedDevice, setConnectDialogOpen, connectDialogOpen } =
@ -25,7 +24,7 @@ export const App = (): JSX.Element => {
const device = getDevice(selectedDevice); const device = getDevice(selectedDevice);
// Sets up light/dark mode based on user preferences or system settings // Sets up light/dark mode based on user preferences or system settings
useTheme() useTheme();
return ( return (
<ErrorBoundary FallbackComponent={ErrorPage}> <ErrorBoundary FallbackComponent={ErrorPage}>
@ -37,28 +36,33 @@ export const App = (): JSX.Element => {
/> />
<Toaster /> <Toaster />
<DeviceWrapper device={device}> <DeviceWrapper device={device}>
<div className="flex h-screen flex-col bg-background-primary text-text-primary" style={{ scrollbarWidth: 'thin' }}> <div
className="flex h-screen flex-col bg-background-primary text-text-primary"
style={{ scrollbarWidth: "thin" }}
>
<SidebarProvider> <SidebarProvider>
<div className="h-full flex flex-col"> <div className="h-full flex flex-col">
{device ? ( {device
<div className="h-full flex w-full"> ? (
<DialogManager /> <div className="h-full flex w-full">
<KeyBackupReminder /> <DialogManager />
<CommandPalette /> <KeyBackupReminder />
<MapProvider> <CommandPalette />
<PageRouter /> <MapProvider>
</MapProvider> <PageRouter />
</div> </MapProvider>
) : ( </div>
<> )
<Dashboard /> : (
<Footer /> <>
</> <Dashboard />
)} <Footer />
</>
)}
</div> </div>
</SidebarProvider> </SidebarProvider>
</div> </div>
</DeviceWrapper> </DeviceWrapper>
</ErrorBoundary > </ErrorBoundary>
); );
}; };

19
src/__mocks__/components/UI/Button.tsx

@ -1,13 +1,13 @@
import { vi } from 'vitest' import { vi } from "vitest";
vi.mock('@components/UI/Button.tsx', () => ({ vi.mock("@components/UI/Button.tsx", () => ({
Button: ({ children, name, disabled, onClick }: { Button: ({ children, name, disabled, onClick }: {
children: React.ReactNode, children: React.ReactNode;
variant: string, variant: string;
name: string, name: string;
disabled?: boolean, disabled?: boolean;
onClick: () => void onClick: () => void;
}) => }) => (
<button <button
type="button" type="button"
name={name} name={name}
@ -17,4 +17,5 @@ vi.mock('@components/UI/Button.tsx', () => ({
> >
{children} {children}
</button> </button>
})); ),
}));

23
src/__mocks__/components/UI/Checkbox.tsx

@ -1,6 +1,19 @@
import { vi } from 'vitest' import { vi } from "vitest";
vi.mock('@components/UI/Checkbox.tsx', () => ({ vi.mock("@components/UI/Checkbox.tsx", () => ({
Checkbox: ({ id, checked, onChange }: { id: string, checked: boolean, onChange: () => void }) => Checkbox: (
<input data-testid="checkbox" type="checkbox" id={id} checked={checked} onChange={onChange} /> { id, checked, onChange }: {
})); id: string;
checked: boolean;
onChange: () => void;
},
) => (
<input
data-testid="checkbox"
type="checkbox"
id={id}
checked={checked}
onChange={onChange}
/>
),
}));

40
src/__mocks__/components/UI/Dialog/Dialog.tsx

@ -1,43 +1,45 @@
import React from 'react'; import React from "react";
export const Dialog = ({ children, open }: { export const Dialog = ({ children, open }: {
children: React.ReactNode, children: React.ReactNode;
open: boolean, open: boolean;
onOpenChange?: (open: boolean) => void onOpenChange?: (open: boolean) => void;
}) => open ? <div data-testid="dialog">{children}</div> : null; }) => open ? <div data-testid="dialog">{children}</div> : null;
export const DialogContent = ({ export const DialogContent = ({
children, children,
className className,
}: { }: {
children: React.ReactNode, children: React.ReactNode;
className?: string className?: string;
}) => <div data-testid="dialog-content" className={className}>{children}</div>; }) => <div data-testid="dialog-content" className={className}>{children}</div>;
export const DialogHeader = ({ export const DialogHeader = ({
children children,
}: { }: {
children: React.ReactNode children: React.ReactNode;
}) => <div data-testid="dialog-header">{children}</div>; }) => <div data-testid="dialog-header">{children}</div>;
export const DialogTitle = ({ export const DialogTitle = ({
children children,
}: { }: {
children: React.ReactNode children: React.ReactNode;
}) => <div data-testid="dialog-title">{children}</div>; }) => <div data-testid="dialog-title">{children}</div>;
export const DialogDescription = ({ export const DialogDescription = ({
children, children,
className className,
}: { }: {
children: React.ReactNode, children: React.ReactNode;
className?: string className?: string;
}) => <div data-testid="dialog-description" className={className}>{children}</div>; }) => (
<div data-testid="dialog-description" className={className}>{children}</div>
);
export const DialogFooter = ({ export const DialogFooter = ({
children, children,
className className,
}: { }: {
children: React.ReactNode, children: React.ReactNode;
className?: string className?: string;
}) => <div data-testid="dialog-footer" className={className}>{children}</div>; }) => <div data-testid="dialog-footer" className={className}>{children}</div>;

19
src/__mocks__/components/UI/Label.tsx

@ -1,6 +1,15 @@
import { vi } from 'vitest' import { vi } from "vitest";
vi.mock('@components/UI/Label.tsx', () => ({ vi.mock("@components/UI/Label.tsx", () => ({
Label: ({ children, htmlFor, className }: { children: React.ReactNode, htmlFor: string, className?: string }) => Label: (
<label data-testid="label" htmlFor={htmlFor} className={className}>{children}</label> { children, htmlFor, className }: {
})); children: React.ReactNode;
htmlFor: string;
className?: string;
},
) => (
<label data-testid="label" htmlFor={htmlFor} className={className}>
{children}
</label>
),
}));

12
src/__mocks__/components/UI/Link.tsx

@ -1,7 +1,11 @@
import { vi } from "vitest"; import { vi } from "vitest";
vi.mock('@components/UI/Typography/Link.tsx', () => ({ vi.mock("@components/UI/Typography/Link.tsx", () => ({
Link: ({ children, href, className }: { children: React.ReactNode, href: string, className?: string }) => Link: (
<a data-testid="link" href={href} className={className}>{children}</a> { children, href, className }: {
children: React.ReactNode;
href: string;
className?: string;
},
) => <a data-testid="link" href={href} className={className}>{children}</a>,
})); }));

46
src/components/BatteryStatus.tsx

@ -1,10 +1,10 @@
import React from 'react'; import React from "react";
import { import {
PlugZapIcon,
BatteryFullIcon, BatteryFullIcon,
BatteryMediumIcon,
BatteryLowIcon, BatteryLowIcon,
} from 'lucide-react'; BatteryMediumIcon,
PlugZapIcon,
} from "lucide-react";
import { Subtle } from "@components/UI/Typography/Subtle.tsx"; import { Subtle } from "@components/UI/Typography/Subtle.tsx";
interface DeviceMetrics { interface DeviceMetrics {
@ -25,50 +25,52 @@ interface BatteryStateConfig {
const batteryStates: BatteryStateConfig[] = [ const batteryStates: BatteryStateConfig[] = [
{ {
condition: level => level > 100, condition: (level) => level > 100,
Icon: PlugZapIcon, Icon: PlugZapIcon,
className: 'text-gray-500', className: "text-gray-500",
text: () => 'Plugged in', text: () => "Plugged in",
}, },
{ {
condition: level => level > 80, condition: (level) => level > 80,
Icon: BatteryFullIcon, Icon: BatteryFullIcon,
className: 'text-green-500', className: "text-green-500",
text: level => `${level}% charging`, text: (level) => `${level}% charging`,
}, },
{ {
condition: level => level > 20, condition: (level) => level > 20,
Icon: BatteryMediumIcon, Icon: BatteryMediumIcon,
className: 'text-yellow-500', className: "text-yellow-500",
text: level => `${level}% charging`, text: (level) => `${level}% charging`,
}, },
{ {
condition: () => true, condition: () => true,
Icon: BatteryLowIcon, Icon: BatteryLowIcon,
className: 'text-red-500', className: "text-red-500",
text: level => `${level}% charging`, text: (level) => `${level}% charging`,
}, },
]; ];
const getBatteryState = (level: number) => { const getBatteryState = (level: number) => {
return batteryStates.find(state => state.condition(level)); return batteryStates.find((state) => state.condition(level));
}; };
const BatteryStatus: React.FC<BatteryStatusProps> = ({ deviceMetrics }) => { const BatteryStatus: React.FC<BatteryStatusProps> = ({ deviceMetrics }) => {
if (deviceMetrics?.batteryLevel === undefined || deviceMetrics?.batteryLevel === null) { if (
deviceMetrics?.batteryLevel === undefined ||
deviceMetrics?.batteryLevel === null
) {
return null; return null;
} }
const { batteryLevel, voltage } = deviceMetrics; const { batteryLevel, voltage } = deviceMetrics;
const currentState = getBatteryState(batteryLevel) ?? batteryStates[batteryStates.length - 1]; const currentState = getBatteryState(batteryLevel) ??
batteryStates[batteryStates.length - 1];
const BatteryIcon = currentState.Icon; const BatteryIcon = currentState.Icon;
const iconClassName = currentState.className; const iconClassName = currentState.className;
const statusText = currentState.text(batteryLevel); const statusText = currentState.text(batteryLevel);
const voltageTitle = `${voltage?.toPrecision(3) ?? 'Unknown'} volts`; const voltageTitle = `${voltage?.toPrecision(3) ?? "Unknown"} volts`;
return ( return (
<div <div
@ -83,4 +85,4 @@ const BatteryStatus: React.FC<BatteryStatusProps> = ({ deviceMetrics }) => {
); );
}; };
export default BatteryStatus; export default BatteryStatus;

34
src/components/CommandPalette/index.tsx

@ -17,8 +17,10 @@ import {
FactoryIcon, FactoryIcon,
LayersIcon, LayersIcon,
LinkIcon, LinkIcon,
type LucideIcon,
MapIcon, MapIcon,
MessageSquareIcon, MessageSquareIcon,
Pin,
PlusIcon, PlusIcon,
PowerIcon, PowerIcon,
QrCodeIcon, QrCodeIcon,
@ -27,8 +29,6 @@ import {
SmartphoneIcon, SmartphoneIcon,
TrashIcon, TrashIcon,
UsersIcon, UsersIcon,
Pin,
type LucideIcon,
} from "lucide-react"; } from "lucide-react";
import { useEffect } from "react"; import { useEffect } from "react";
import { Avatar } from "@components/UI/Avatar.tsx"; import { Avatar } from "@components/UI/Avatar.tsx";
@ -61,7 +61,9 @@ export const CommandPalette = () => {
} = useAppStore(); } = useAppStore();
const { getDevices } = useDeviceStore(); const { getDevices } = useDeviceStore();
const { setDialogOpen, setActivePage, getNode, connection } = useDevice(); const { setDialogOpen, setActivePage, getNode, connection } = useDevice();
const { pinnedItems, togglePinnedItem } = usePinnedItems({ storageName: 'pinnedCommandMenuGroups' }); const { pinnedItems, togglePinnedItem } = usePinnedItems({
storageName: "pinnedCommandMenuGroups",
});
const groups: Group[] = [ const groups: Group[] = [
{ {
@ -114,15 +116,12 @@ export const CommandPalette = () => {
label: "Switch Node", label: "Switch Node",
icon: ArrowLeftRightIcon, icon: ArrowLeftRightIcon,
subItems: getDevices().map((device) => ({ subItems: getDevices().map((device) => ({
label: label: getNode(device.hardware.myNodeNum)?.user?.longName ??
getNode(device.hardware.myNodeNum)?.user?.longName ??
device.hardware.myNodeNum.toString(), device.hardware.myNodeNum.toString(),
icon: ( icon: (
<Avatar <Avatar
text={ text={getNode(device.hardware.myNodeNum)?.user?.shortName ??
getNode(device.hardware.myNodeNum)?.user?.shortName ?? device.hardware.myNodeNum.toString()}
device.hardware.myNodeNum.toString()
}
/> />
), ),
action() { action() {
@ -248,7 +247,10 @@ export const CommandPalette = () => {
}, [setCommandPaletteOpen]); }, [setCommandPaletteOpen]);
return ( return (
<CommandDialog open={commandPaletteOpen} onOpenChange={setCommandPaletteOpen}> <CommandDialog
open={commandPaletteOpen}
onOpenChange={setCommandPaletteOpen}
>
<CommandInput placeholder="Type a command or search..." /> <CommandInput placeholder="Type a command or search..." />
<CommandList> <CommandList>
<CommandEmpty>No results found.</CommandEmpty> <CommandEmpty>No results found.</CommandEmpty>
@ -262,13 +264,11 @@ export const CommandPalette = () => {
type="button" type="button"
onClick={() => togglePinnedItem(group.label)} onClick={() => togglePinnedItem(group.label)}
className={cn( 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 p-2 focus:*:data-label:opacity-100",
)} )}
aria-description={ aria-description={pinnedItems.includes(group.label)
pinnedItems.includes(group.label) ? "Unpin command group"
? "Unpin command group" : "Pin command group"}
: "Pin command group"
}
> >
<span <span
data-label data-label
@ -280,7 +280,7 @@ export const CommandPalette = () => {
"transition-opacity", "transition-opacity",
pinnedItems.includes(group.label) pinnedItems.includes(group.label)
? "opacity-100 text-red-500" ? "opacity-100 text-red-500"
: "opacity-40 hover:opacity-70" : "opacity-40 hover:opacity-70",
)} )}
/> />
</button> </button>

67
src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.test.tsx

@ -1,16 +1,16 @@
import { render, screen, fireEvent } from '@testing-library/react'; import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from "vitest";
// Ensure the path is correct for import // Ensure the path is correct for import
import { useMessageStore } from "../../../core/stores/messageStore/index.ts"; import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
import { DeleteMessagesDialog } from "@components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx"; import { DeleteMessagesDialog } from "@components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx";
vi.mock('@core/stores/messageStore', () => ({ vi.mock("@core/stores/messageStore", () => ({
useMessageStore: vi.fn(() => ({ useMessageStore: vi.fn(() => ({
deleteAllMessages: vi.fn(), deleteAllMessages: vi.fn(),
})), })),
})); }));
describe('DeleteMessagesDialog', () => { describe("DeleteMessagesDialog", () => {
const mockOnOpenChange = vi.fn(); const mockOnOpenChange = vi.fn();
const mockClearAllMessages = vi.fn(); const mockClearAllMessages = vi.fn();
@ -20,50 +20,61 @@ describe('DeleteMessagesDialog', () => {
const mockedUseMessageStore = vi.mocked(useMessageStore); const mockedUseMessageStore = vi.mocked(useMessageStore);
mockedUseMessageStore.mockImplementation(() => ({ mockedUseMessageStore.mockImplementation(() => ({
deleteAllMessages: mockClearAllMessages deleteAllMessages: mockClearAllMessages,
})); }));
mockedUseMessageStore.mockClear(); mockedUseMessageStore.mockClear();
}); });
it('calls onOpenChange with false when the close button (X) is clicked', () => { it("calls onOpenChange with false when the close button (X) is clicked", () => {
render(<DeleteMessagesDialog open={true} onOpenChange={mockOnOpenChange} />); render(
const closeButton = screen.queryByTestId('dialog-close-button'); <DeleteMessagesDialog open onOpenChange={mockOnOpenChange} />,
);
const closeButton = screen.queryByTestId("dialog-close-button");
if (!closeButton) { if (!closeButton) {
throw new Error("Dialog close button with data-testid='dialog-close-button' not found. Did you add it to the component?"); throw new Error(
"Dialog close button with data-testid='dialog-close-button' not found. Did you add it to the component?",
);
} }
fireEvent.click(closeButton); fireEvent.click(closeButton);
expect(mockOnOpenChange).toHaveBeenCalledTimes(1); expect(mockOnOpenChange).toHaveBeenCalledTimes(1);
expect(mockOnOpenChange).toHaveBeenCalledWith(false); expect(mockOnOpenChange).toHaveBeenCalledWith(false);
}); });
it("renders the dialog when open is true", () => {
render(
it('renders the dialog when open is true', () => { <DeleteMessagesDialog open onOpenChange={mockOnOpenChange} />,
render(<DeleteMessagesDialog open={true} onOpenChange={mockOnOpenChange} />); );
expect(screen.getByText('Clear All Messages')).toBeInTheDocument(); expect(screen.getByText("Clear All Messages")).toBeInTheDocument();
expect(screen.getByText(/This action will clear all message history./)).toBeInTheDocument(); expect(screen.getByText(/This action will clear all message history./))
expect(screen.getByRole('button', { name: 'Dismiss' })).toBeInTheDocument(); .toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Clear Messages' })).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', () => { it("does not render the dialog when open is false", () => {
render(<DeleteMessagesDialog open={false} onOpenChange={mockOnOpenChange} />); render(
expect(screen.queryByText('Clear All Messages')).toBeNull(); <DeleteMessagesDialog open={false} onOpenChange={mockOnOpenChange} />,
);
expect(screen.queryByText("Clear All Messages")).toBeNull();
}); });
it('calls onOpenChange with false when the dismiss button is clicked', () => { it("calls onOpenChange with false when the dismiss button is clicked", () => {
render(<DeleteMessagesDialog open={true} onOpenChange={mockOnOpenChange} />); render(
fireEvent.click(screen.getByRole('button', { name: 'Dismiss' })); <DeleteMessagesDialog open onOpenChange={mockOnOpenChange} />,
);
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
expect(mockOnOpenChange).toHaveBeenCalledTimes(1); // Add count check expect(mockOnOpenChange).toHaveBeenCalledTimes(1); // Add count check
expect(mockOnOpenChange).toHaveBeenCalledWith(false); expect(mockOnOpenChange).toHaveBeenCalledWith(false);
}); });
it('calls deleteAllMessages and onOpenChange with false when the clear messages button is clicked', () => { it("calls deleteAllMessages and onOpenChange with false when the clear messages button is clicked", () => {
render(<DeleteMessagesDialog open={true} onOpenChange={mockOnOpenChange} />); render(
fireEvent.click(screen.getByRole('button', { name: 'Clear Messages' })); <DeleteMessagesDialog open onOpenChange={mockOnOpenChange} />,
);
fireEvent.click(screen.getByRole("button", { name: "Clear Messages" }));
expect(mockClearAllMessages).toHaveBeenCalledTimes(1); expect(mockClearAllMessages).toHaveBeenCalledTimes(1);
expect(mockOnOpenChange).toHaveBeenCalledTimes(1); // Add count check expect(mockOnOpenChange).toHaveBeenCalledTimes(1); // Add count check
expect(mockOnOpenChange).toHaveBeenCalledWith(false); expect(mockOnOpenChange).toHaveBeenCalledWith(false);
}); });
}); });

1
src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx

@ -1,4 +1,3 @@
import { Button } from "@components/UI/Button.tsx"; import { Button } from "@components/UI/Button.tsx";
import { import {
Dialog, Dialog,

18
src/components/Dialog/DeviceNameDialog.tsx

@ -44,8 +44,14 @@ export const DeviceNameDialog = ({
values: defaultValues, values: defaultValues,
}); });
const { currentLength: currentLongNameLength } = validateMaxByteLength(getValues('longName'), MAX_LONG_NAME_BYTE_LENGTH); const { currentLength: currentLongNameLength } = validateMaxByteLength(
const { currentLength: currentShortNameLength } = validateMaxByteLength(getValues('shortName'), MAX_SHORT_NAME_BYTE_LENGTH); getValues("longName"),
MAX_LONG_NAME_BYTE_LENGTH,
);
const { currentLength: currentShortNameLength } = validateMaxByteLength(
getValues("shortName"),
MAX_SHORT_NAME_BYTE_LENGTH,
);
const onSubmit = handleSubmit((data) => { const onSubmit = handleSubmit((data) => {
connection?.setOwner( connection?.setOwner(
@ -83,7 +89,7 @@ export const DeviceNameDialog = ({
label: "Long Name", label: "Long Name",
type: "text", type: "text",
properties: { properties: {
className: 'text-slate-900 dark:text-slate-200', className: "text-slate-900 dark:text-slate-200",
fieldLength: { fieldLength: {
currentValueLength: currentLongNameLength ?? 0, currentValueLength: currentLongNameLength ?? 0,
max: MAX_LONG_NAME_BYTE_LENGTH, max: MAX_LONG_NAME_BYTE_LENGTH,
@ -113,11 +119,13 @@ export const DeviceNameDialog = ({
</div> </div>
<DialogFooter> <DialogFooter>
<Button type="button" variant="destructive" onClick={handleReset}>Reset</Button> <Button type="button" variant="destructive" onClick={handleReset}>
Reset
</Button>
<Button type="submit">Save</Button> <Button type="submit">Save</Button>
</DialogFooter> </DialogFooter>
</form> </form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
}; };

1
src/components/Dialog/DialogManager.tsx

@ -12,7 +12,6 @@ import { RefreshKeysDialog } from "@components/Dialog/RefreshKeysDialog/RefreshK
import { RebootOTADialog } from "@components/Dialog/RebootOTADialog.tsx"; import { RebootOTADialog } from "@components/Dialog/RebootOTADialog.tsx";
import { DeleteMessagesDialog } from "@components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx"; import { DeleteMessagesDialog } from "@components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx";
export const DialogManager = () => { export const DialogManager = () => {
const { channels, config, dialog, setDialogOpen } = useDevice(); const { channels, config, dialog, setDialogOpen } = useDevice();
return ( return (

2
src/components/Dialog/ImportDialog.tsx

@ -51,7 +51,7 @@ export const ImportDialog = ({
const paddedString = encodedChannelConfig const paddedString = encodedChannelConfig
.padEnd( .padEnd(
encodedChannelConfig.length + encodedChannelConfig.length +
((4 - (encodedChannelConfig.length % 4)) % 4), ((4 - (encodedChannelConfig.length % 4)) % 4),
"=", "=",
) )
.replace(/-/g, "+") .replace(/-/g, "+")

5
src/components/Dialog/LocationResponseDialog.tsx

@ -43,8 +43,9 @@ export const LocationResponseDialog = ({
Coordinates:{" "} Coordinates:{" "}
<a <a
className="text-blue-500 dark:text-blue-400" className="text-blue-500 dark:text-blue-400"
href={`https://www.openstreetmap.org/?mlat=${location?.data.latitudeI / 1e7 href={`https://www.openstreetmap.org/?mlat=${
}&mlon=${location?.data.longitudeI / 1e7}&layers=N`} location?.data.latitudeI / 1e7
}&mlon=${location?.data.longitudeI / 1e7}&layers=N`}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
> >

14
src/components/Dialog/NewDeviceDialog.tsx

@ -90,8 +90,8 @@ const ErrorMessage = ({ missingFeatures }: FeatureErrorProps) => {
{browserFeatures.length > 0 && ( {browserFeatures.length > 0 && (
<> <>
This connection type requires{" "} This connection type requires{" "}
{formatFeatureList(browserFeatures)}. Please use a {formatFeatureList(browserFeatures)}. Please use a supported
supported browser, like Chrome or Edge. browser, like Chrome or Edge.
</> </>
)} )}
{needsSecureContext && ( {needsSecureContext && (
@ -114,11 +114,9 @@ export const NewDeviceDialog = ({
open, open,
onOpenChange, onOpenChange,
}: NewDeviceProps) => { }: NewDeviceProps) => {
const [connectionInProgress, setConnectionInProgress] = const [connectionInProgress, setConnectionInProgress] = useState(false);
useState(false);
const { unsupported } = useBrowserFeatureDetection(); const { unsupported } = useBrowserFeatureDetection();
const tabs: TabManifest[] = [ const tabs: TabManifest[] = [
{ {
label: "HTTP", label: "HTTP",
@ -160,7 +158,11 @@ export const NewDeviceDialog = ({
{tab.isDisabled {tab.isDisabled
? <ErrorMessage missingFeatures={unsupported} /> ? <ErrorMessage missingFeatures={unsupported} />
: null} : null}
<tab.element closeDialog={() => onOpenChange(false)} setConnectionInProgress={setConnectionInProgress} connectionInProgress={connectionInProgress} /> <tab.element
closeDialog={() => onOpenChange(false)}
setConnectionInProgress={setConnectionInProgress}
connectionInProgress={connectionInProgress}
/>
</fieldset> </fieldset>
</TabsContent> </TabsContent>
))} ))}

33
src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.test.tsx

@ -1,4 +1,4 @@
import { describe, it, vi, expect, beforeEach } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx"; import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
@ -11,7 +11,6 @@ vi.mock("@core/stores/appStore");
const mockUseDevice = vi.mocked(useDevice); const mockUseDevice = vi.mocked(useDevice);
const mockUseAppStore = vi.mocked(useAppStore); const mockUseAppStore = vi.mocked(useAppStore);
describe("NodeDetailsDialog", () => { describe("NodeDetailsDialog", () => {
const mockDevice = { const mockDevice = {
num: 1234, num: 1234,
@ -54,30 +53,33 @@ describe("NodeDetailsDialog", () => {
}); });
it("renders node details correctly", () => { it("renders node details correctly", () => {
render(<NodeDetailsDialog open={true} onOpenChange={() => { }} />); render(<NodeDetailsDialog open onOpenChange={() => {}} />);
expect(screen.getByText(/Node Details for Test Node \(TN\)/i)).toBeInTheDocument(); expect(screen.getByText(/Node Details for Test Node \(TN\)/i))
.toBeInTheDocument();
expect(screen.getByText("Node Number: 1234")).toBeInTheDocument(); expect(screen.getByText("Node Number: 1234")).toBeInTheDocument();
expect(screen.getByText(/Node Hex: !/i)).toBeInTheDocument(); expect(screen.getByText(/Node Hex: !/i)).toBeInTheDocument();
expect(screen.getByText(/Last Heard:/i)).toBeInTheDocument(); expect(screen.getByText(/Last Heard:/i)).toBeInTheDocument();
expect(screen.getByText(/Coordinates:/i)).toBeInTheDocument(); expect(screen.getByText(/Coordinates:/i)).toBeInTheDocument();
const link = screen.getByRole('link', { name: /^45, -75$/ }); const link = screen.getByRole("link", { name: /^45, -75$/ });
expect(link).toBeInTheDocument(); expect(link).toBeInTheDocument();
expect(link).toHaveAttribute('href', expect.stringContaining('openstreetmap.org')); expect(link).toHaveAttribute(
"href",
expect.stringContaining("openstreetmap.org"),
);
expect(screen.getByText(/Altitude: 200m/i)).toBeInTheDocument(); expect(screen.getByText(/Altitude: 200m/i)).toBeInTheDocument();
expect(screen.getByText(/Air TX utilization: 50.12%/i)).toBeInTheDocument(); expect(screen.getByText(/Air TX utilization: 50.12%/i)).toBeInTheDocument();
expect(screen.getByText(/Channel utilization: 75.46%/i)).toBeInTheDocument(); expect(screen.getByText(/Channel utilization: 75.46%/i))
.toBeInTheDocument();
expect(screen.getByText(/Battery level: 88.79%/i)).toBeInTheDocument(); expect(screen.getByText(/Battery level: 88.79%/i)).toBeInTheDocument();
expect(screen.getByText(/Voltage: 4.20V/i)).toBeInTheDocument(); expect(screen.getByText(/Voltage: 4.20V/i)).toBeInTheDocument();
expect(screen.getByText(/Uptime:/i)).toBeInTheDocument(); expect(screen.getByText(/Uptime:/i)).toBeInTheDocument();
expect(screen.getByText(/All Raw Metrics:/i)).toBeInTheDocument(); expect(screen.getByText(/All Raw Metrics:/i)).toBeInTheDocument();
}); });
it("renders null if device is not found", () => { it("renders null if device is not found", () => {
@ -99,7 +101,9 @@ describe("NodeDetailsDialog", () => {
}, },
}); });
const { container } = render(<NodeDetailsDialog open={true} onOpenChange={() => { }} />); const { container } = render(
<NodeDetailsDialog open onOpenChange={() => {}} />,
);
expect(container.firstChild).toBeNull(); expect(container.firstChild).toBeNull();
expect(screen.queryByText(/Node Details for/i)).not.toBeInTheDocument(); expect(screen.queryByText(/Node Details for/i)).not.toBeInTheDocument();
@ -110,7 +114,7 @@ describe("NodeDetailsDialog", () => {
mockUseDevice.mockReturnValue({ getNode: () => nodeWithoutPosition }); mockUseDevice.mockReturnValue({ getNode: () => nodeWithoutPosition });
mockUseAppStore.mockReturnValue({ nodeNumDetails: 1234 }); mockUseAppStore.mockReturnValue({ nodeNumDetails: 1234 });
render(<NodeDetailsDialog open={true} onOpenChange={() => { }} />); render(<NodeDetailsDialog open onOpenChange={() => {}} />);
expect(screen.queryByText(/Coordinates:/i)).not.toBeInTheDocument(); expect(screen.queryByText(/Coordinates:/i)).not.toBeInTheDocument();
expect(screen.queryByText(/Altitude:/i)).not.toBeInTheDocument(); expect(screen.queryByText(/Altitude:/i)).not.toBeInTheDocument();
@ -122,7 +126,7 @@ describe("NodeDetailsDialog", () => {
mockUseDevice.mockReturnValue({ getNode: () => nodeWithoutMetrics }); mockUseDevice.mockReturnValue({ getNode: () => nodeWithoutMetrics });
mockUseAppStore.mockReturnValue({ nodeNumDetails: 1234 }); mockUseAppStore.mockReturnValue({ nodeNumDetails: 1234 });
render(<NodeDetailsDialog open={true} onOpenChange={() => { }} />); render(<NodeDetailsDialog open onOpenChange={() => {}} />);
expect(screen.queryByText(/Device Metrics:/i)).not.toBeInTheDocument(); expect(screen.queryByText(/Device Metrics:/i)).not.toBeInTheDocument();
expect(screen.queryByText(/Air TX utilization:/i)).not.toBeInTheDocument(); expect(screen.queryByText(/Air TX utilization:/i)).not.toBeInTheDocument();
@ -134,9 +138,8 @@ describe("NodeDetailsDialog", () => {
mockUseDevice.mockReturnValue({ getNode: () => nodeNeverHeard }); mockUseDevice.mockReturnValue({ getNode: () => nodeNeverHeard });
mockUseAppStore.mockReturnValue({ nodeNumDetails: 1234 }); mockUseAppStore.mockReturnValue({ nodeNumDetails: 1234 });
render(<NodeDetailsDialog open={true} onOpenChange={() => { }} />); render(<NodeDetailsDialog open onOpenChange={() => {}} />);
expect(screen.getByText(/Last Heard: Never/i)).toBeInTheDocument(); expect(screen.getByText(/Last Heard: Never/i)).toBeInTheDocument();
}); });
});
});

28
src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx

@ -65,7 +65,7 @@ export const NodeDetailsDialog = ({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent > <DialogContent>
<DialogClose /> <DialogClose />
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
@ -78,9 +78,8 @@ export const NodeDetailsDialog = ({
<div className="flex flex-col"> <div className="flex flex-col">
<DeviceImage <DeviceImage
className="w-32 h-32 mx-auto rounded-lg border-4 border-slate-200 dark:border-slate-800" className="w-32 h-32 mx-auto rounded-lg border-4 border-slate-200 dark:border-slate-800"
deviceType={ deviceType={Protobuf.Mesh
Protobuf.Mesh.HardwareModel[device.user?.hwModel ?? 0] .HardwareModel[device.user?.hwModel ?? 0]}
}
/> />
<div className="bg-slate-100 text-slate-900 dark:text-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3"> <div className="bg-slate-100 text-slate-900 dark:text-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
<p className="text-lg font-semibold">Details:</p> <p className="text-lg font-semibold">Details:</p>
@ -91,16 +90,14 @@ export const NodeDetailsDialog = ({
<p>Node Number: {device.num}</p> <p>Node Number: {device.num}</p>
<p>Node Hex: !{numberToHexUnpadded(device.num)}</p> <p>Node Hex: !{numberToHexUnpadded(device.num)}</p>
<p> <p>
Role:{" "} Role: {Protobuf.Config.Config_DeviceConfig_Role[
{
Protobuf.Config.Config_DeviceConfig_Role[
device.user?.role ?? 0 device.user?.role ?? 0
] ]}
}
</p> </p>
<p> <p>
Last Heard:{" "} Last Heard: {device.lastHeard === 0
{device.lastHeard === 0 ? "Never" : <TimeAgo timestamp={device.lastHeard * 1000} />} ? "Never"
: <TimeAgo timestamp={device.lastHeard * 1000} />}
</p> </p>
</div> </div>
@ -112,9 +109,9 @@ export const NodeDetailsDialog = ({
Coordinates:{" "} Coordinates:{" "}
<a <a
className="text-blue-500 dark:text-blue-400" className="text-blue-500 dark:text-blue-400"
href={`https://www.openstreetmap.org/?mlat=${device.position.latitudeI / 1e7 href={`https://www.openstreetmap.org/?mlat=${
}&mlon=${device.position.longitudeI / 1e7 device.position.latitudeI / 1e7
}&layers=N`} }&mlon=${device.position.longitudeI / 1e7}&layers=N`}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
> >
@ -140,7 +137,7 @@ export const NodeDetailsDialog = ({
<p key={metric.key}> <p key={metric.key}>
{metric.label}: {metric.format(metric.value)} {metric.label}: {metric.format(metric.value)}
</p> </p>
) ),
)} )}
{device.deviceMetrics.uptimeSeconds && ( {device.deviceMetrics.uptimeSeconds && (
<p> <p>
@ -150,7 +147,6 @@ export const NodeDetailsDialog = ({
)} )}
</div> </div>
)} )}
</div> </div>
<div className="text-slate-900 dark:text-slate-100 w-full max-w-[464px] bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3"> <div className="text-slate-900 dark:text-slate-100 w-full max-w-[464px] bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">

5
src/components/Dialog/NodeOptionsDialog.tsx

@ -13,7 +13,10 @@ import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { TrashIcon } from "lucide-react"; import { TrashIcon } from "lucide-react";
import { Button } from "../UI/Button.tsx"; import { Button } from "../UI/Button.tsx";
import { MessageType, useMessageStore } from "../../core/stores/messageStore/index.ts"; import {
MessageType,
useMessageStore,
} from "../../core/stores/messageStore/index.ts";
export interface NodeOptionsDialogProps { export interface NodeOptionsDialogProps {
node: Protobuf.Mesh.NodeInfo | undefined; node: Protobuf.Mesh.NodeInfo | undefined;

2
src/components/Dialog/PkiRegenerateDialog.tsx

@ -14,7 +14,7 @@ export interface PkiRegenerateDialogProps {
title: string; title: string;
description: string; description: string;
button: string; button: string;
} };
open: boolean; open: boolean;
onOpenChange: () => void; onOpenChange: () => void;
onSubmit: () => void; onSubmit: () => void;

24
src/components/Dialog/QRDialog.tsx

@ -79,8 +79,8 @@ export const QRDialog = ({
{channel.settings?.name.length {channel.settings?.name.length
? channel.settings.name ? channel.settings.name
: channel.role === Protobuf.Channel.Channel_Role.PRIMARY : channel.role === Protobuf.Channel.Channel_Role.PRIMARY
? "Primary" ? "Primary"
: `Channel: ${channel.index}`} : `Channel: ${channel.index}`}
</Label> </Label>
<Checkbox <Checkbox
key={channel.index} key={channel.index}
@ -108,20 +108,22 @@ export const QRDialog = ({
<div className="flex justify-center"> <div className="flex justify-center">
<button <button
type="button" type="button"
className={`border-slate-900 border-t border-l border-b rounded-l h-10 px-7 py-2 text-sm font-medium focus:outline-hidden focus:ring-2 focus:ring-offset-2 ${qrCodeAdd className={`border-slate-900 border-t border-l border-b rounded-l h-10 px-7 py-2 text-sm font-medium focus:outline-hidden focus:ring-2 focus:ring-offset-2 ${
? "focus:ring-green-800 bg-green-800 text-white" qrCodeAdd
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600" ? "focus:ring-green-800 bg-green-800 text-white"
}`} : "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
}`}
onClick={() => setQrCodeAdd(true)} onClick={() => setQrCodeAdd(true)}
> >
Add Channels Add Channels
</button> </button>
<button <button
type="button" type="button"
className={`border-slate-900 border-t border-r border-b rounded-r h-10 px-4 py-2 text-sm font-medium focus:outline-hidden focus:ring-2 focus:ring-offset-2 ${!qrCodeAdd className={`border-slate-900 border-t border-r border-b rounded-r h-10 px-4 py-2 text-sm font-medium focus:outline-hidden focus:ring-2 focus:ring-offset-2 ${
? "focus:ring-green-800 bg-green-800 text-white" !qrCodeAdd
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600" ? "focus:ring-green-800 bg-green-800 text-white"
}`} : "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
}`}
onClick={() => setQrCodeAdd(false)} onClick={() => setQrCodeAdd(false)}
> >
Replace Channels Replace Channels
@ -134,7 +136,7 @@ export const QRDialog = ({
value={qrCodeUrl} value={qrCodeUrl}
disabled disabled
action={{ action={{
key: 'copy-value', key: "copy-value",
icon: ClipboardIcon, icon: ClipboardIcon,
onClick() { onClick() {
void navigator.clipboard.writeText(qrCodeUrl); void navigator.clipboard.writeText(qrCodeUrl);

56
src/components/Dialog/RebootOTADialog.test.tsx

@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { RebootOTADialog } from './RebootOTADialog.tsx'; import { RebootOTADialog } from "./RebootOTADialog.tsx";
import { ReactNode } from "react"; import { ReactNode } from "react";
const rebootOtaMock = vi.fn(); const rebootOtaMock = vi.fn();
@ -8,41 +8,46 @@ let mockConnection: { rebootOta: (delay: number) => void } | undefined = {
rebootOta: rebootOtaMock, rebootOta: rebootOtaMock,
}; };
vi.mock('@core/stores/deviceStore.ts', () => ({ vi.mock("@core/stores/deviceStore.ts", () => ({
useDevice: () => ({ useDevice: () => ({
connection: mockConnection, connection: mockConnection,
}), }),
})); }));
vi.mock('@components/UI/Button.tsx', async () => { vi.mock("@components/UI/Button.tsx", async () => {
const actual = await vi.importActual('@components/UI/Button.tsx'); const actual = await vi.importActual("@components/UI/Button.tsx");
return { return {
...actual, ...actual,
Button: (props: any) => <button {...props} />, Button: (props) => <button {...props} />,
}; };
}); });
vi.mock('@components/UI/Input.tsx', async () => { vi.mock("@components/UI/Input.tsx", async () => {
const actual = await vi.importActual('@components/UI/Input.tsx'); const actual = await vi.importActual("@components/UI/Input.tsx");
return { return {
...actual, ...actual,
Input: (props: any) => <input {...props} />, Input: (props) => <input {...props} />,
}; };
}); });
vi.mock('@components/UI/Dialog.tsx', () => { vi.mock("@components/UI/Dialog.tsx", () => {
return { return {
Dialog: ({ children }: { children: ReactNode }) => <div>{children}</div>, Dialog: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>, DialogContent: ({ children }: { children: ReactNode }) => (
DialogHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>, <div>{children}</div>
),
DialogHeader: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
DialogTitle: ({ children }: { children: ReactNode }) => <h1>{children}</h1>, DialogTitle: ({ children }: { children: ReactNode }) => <h1>{children}</h1>,
DialogDescription: ({ children }: { children: ReactNode }) => <p>{children}</p>, DialogDescription: ({ children }: { children: ReactNode }) => (
<p>{children}</p>
),
DialogClose: () => null, DialogClose: () => null,
}; };
}); });
describe("RebootOTADialog", () => {
describe('RebootOTADialog', () => {
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers(); vi.useFakeTimers();
rebootOtaMock.mockClear(); rebootOtaMock.mockClear();
@ -52,19 +57,19 @@ describe('RebootOTADialog', () => {
vi.useRealTimers(); vi.useRealTimers();
}); });
it('renders dialog with default input value', () => { it("renders dialog with default input value", () => {
render(<RebootOTADialog open={true} onOpenChange={() => { }} />); render(<RebootOTADialog open onOpenChange={() => {}} />);
expect(screen.getByPlaceholderText(/enter delay/i)).toHaveValue(5); expect(screen.getByPlaceholderText(/enter delay/i)).toHaveValue(5);
expect(screen.getByText(/schedule reboot/i)).toBeInTheDocument(); expect(screen.getByText(/schedule reboot/i)).toBeInTheDocument();
expect(screen.getByText(/reboot to ota mode now/i)).toBeInTheDocument(); expect(screen.getByText(/reboot to ota mode now/i)).toBeInTheDocument();
}); });
it('schedules a reboot with delay and calls rebootOta', async () => { it("schedules a reboot with delay and calls rebootOta", async () => {
const onOpenChangeMock = vi.fn(); const onOpenChangeMock = vi.fn();
render(<RebootOTADialog open={true} onOpenChange={onOpenChangeMock} />); render(<RebootOTADialog open onOpenChange={onOpenChangeMock} />);
fireEvent.change(screen.getByPlaceholderText(/enter delay/i), { fireEvent.change(screen.getByPlaceholderText(/enter delay/i), {
target: { value: '3' }, target: { value: "3" },
}); });
fireEvent.click(screen.getByText(/schedule reboot/i)); fireEvent.click(screen.getByText(/schedule reboot/i));
@ -79,9 +84,9 @@ describe('RebootOTADialog', () => {
}); });
}); });
it('triggers an instant reboot', async () => { it("triggers an instant reboot", async () => {
const onOpenChangeMock = vi.fn(); const onOpenChangeMock = vi.fn();
render(<RebootOTADialog open={true} onOpenChange={onOpenChangeMock} />); render(<RebootOTADialog open onOpenChange={onOpenChangeMock} />);
fireEvent.click(screen.getByText(/reboot to ota mode now/i)); fireEvent.click(screen.getByText(/reboot to ota mode now/i));
@ -91,13 +96,13 @@ describe('RebootOTADialog', () => {
}); });
}); });
it('does not call reboot if connection is undefined', async () => { it("does not call reboot if connection is undefined", async () => {
const onOpenChangeMock = vi.fn(); const onOpenChangeMock = vi.fn();
// simulate no connection // simulate no connection
mockConnection = undefined; mockConnection = undefined;
render(<RebootOTADialog open={true} onOpenChange={onOpenChangeMock} />); render(<RebootOTADialog open onOpenChange={onOpenChangeMock} />);
fireEvent.click(screen.getByText(/schedule reboot/i)); fireEvent.click(screen.getByText(/schedule reboot/i));
vi.advanceTimersByTime(5000); vi.advanceTimersByTime(5000);
@ -110,5 +115,4 @@ describe('RebootOTADialog', () => {
// reset connection for other tests // reset connection for other tests
mockConnection = { rebootOta: rebootOtaMock }; mockConnection = { rebootOta: rebootOtaMock };
}); });
}); });

14
src/components/Dialog/RebootOTADialog.tsx

@ -19,7 +19,9 @@ export interface RebootOTADialogProps {
const DEFAULT_REBOOT_DELAY = 5; // seconds const DEFAULT_REBOOT_DELAY = 5; // seconds
export const RebootOTADialog = ({ open, onOpenChange }: RebootOTADialogProps) => { export const RebootOTADialog = (
{ open, onOpenChange }: RebootOTADialogProps,
) => {
const { connection } = useDevice(); const { connection } = useDevice();
const [time, setTime] = useState<number>(DEFAULT_REBOOT_DELAY); const [time, setTime] = useState<number>(DEFAULT_REBOOT_DELAY);
const [isScheduled, setIsScheduled] = useState(false); const [isScheduled, setIsScheduled] = useState(false);
@ -28,8 +30,8 @@ export const RebootOTADialog = ({ open, onOpenChange }: RebootOTADialogProps) =>
const handleSetTime = (e: React.ChangeEvent<HTMLInputElement>) => { const handleSetTime = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.validity.valid) { if (!e.target.validity.valid) {
e.preventDefault(); e.preventDefault();
return return;
}; }
const val = e.target.value; const val = e.target.value;
setInputValue(val); setInputValue(val);
@ -73,7 +75,8 @@ export const RebootOTADialog = ({ open, onOpenChange }: RebootOTADialogProps) =>
<DialogHeader> <DialogHeader>
<DialogTitle>Reboot to OTA Mode</DialogTitle> <DialogTitle>Reboot to OTA Mode</DialogTitle>
<DialogDescription> <DialogDescription>
Reboot the connected node after a delay into OTA (Over-the-Air) mode. Reboot the connected node after a delay into OTA (Over-the-Air)
mode.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@ -89,7 +92,7 @@ export const RebootOTADialog = ({ open, onOpenChange }: RebootOTADialogProps) =>
/> />
<Button onClick={() => handleRebootWithTimeout()} className="w-9/12"> <Button onClick={() => handleRebootWithTimeout()} className="w-9/12">
<ClockIcon className="mr-2" size={18} /> <ClockIcon className="mr-2" size={18} />
{isScheduled ? 'Reboot has been scheduled' : 'Schedule Reboot'} {isScheduled ? "Reboot has been scheduled" : "Schedule Reboot"}
</Button> </Button>
</div> </div>
@ -101,4 +104,3 @@ export const RebootOTADialog = ({ open, onOpenChange }: RebootOTADialogProps) =>
</Dialog> </Dialog>
); );
}; };

30
src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.test.tsx

@ -3,7 +3,7 @@ import { DeviceContext, useDeviceStore } from "@core/stores/deviceStore.ts";
import { RefreshKeysDialog } from "./RefreshKeysDialog.tsx"; import { RefreshKeysDialog } from "./RefreshKeysDialog.tsx";
import { useMessageStore } from "../../../core/stores/messageStore/index.ts"; import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts"; import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
import { expect, test, vi, beforeEach, afterEach } from 'vitest'; import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { Protobuf } from "@meshtastic/core"; import { Protobuf } from "@meshtastic/core";
vi.mock("@core/stores/messageStore"); vi.mock("@core/stores/messageStore");
@ -12,7 +12,9 @@ vi.mock("./useRefreshKeysDialog");
const mockUseMessageStore = vi.mocked(useMessageStore); const mockUseMessageStore = vi.mocked(useMessageStore);
const mockUseRefreshKeysDialog = vi.mocked(useRefreshKeysDialog); const mockUseRefreshKeysDialog = vi.mocked(useRefreshKeysDialog);
const getInitialState = () => useDeviceStore.getInitialState?.() ?? { devices: new Map(), remoteDevices: new Map() }; const getInitialState = () =>
useDeviceStore.getInitialState?.() ??
{ devices: new Map(), remoteDevices: new Map() };
beforeEach(() => { beforeEach(() => {
useDeviceStore.setState(getInitialState(), true); useDeviceStore.setState(getInitialState(), true);
@ -39,17 +41,19 @@ test("renders dialog when there is a node error for the active chat", () => {
longName: "Problem Node Long", longName: "Problem Node Long",
shortName: "ProbNode", shortName: "ProbNode",
isLicensed: false, isLicensed: false,
macaddr: new Uint8Array(0) macaddr: new Uint8Array(0),
}, },
lastHeard: Date.now() / 1000, lastHeard: Date.now() / 1000,
snr: 10 snr: 10,
} as Protobuf.Mesh.NodeInfo); } as Protobuf.Mesh.NodeInfo);
deviceStore.setNodeError(activeChatNum, "PKI_MISMATCH"); deviceStore.setNodeError(activeChatNum, "PKI_MISMATCH");
const updatedDeviceState = useDeviceStore.getState().getDevice(deviceId); const updatedDeviceState = useDeviceStore.getState().getDevice(deviceId);
if (!updatedDeviceState) { if (!updatedDeviceState) {
throw new Error("Failed to get updated device state from store for provider"); throw new Error(
"Failed to get updated device state from store for provider",
);
} }
mockUseMessageStore.mockReturnValue({ activeChat: activeChatNum }); mockUseMessageStore.mockReturnValue({ activeChat: activeChatNum });
@ -63,12 +67,18 @@ test("renders dialog when there is a node error for the active chat", () => {
render( render(
<DeviceContext.Provider value={updatedDeviceState}> <DeviceContext.Provider value={updatedDeviceState}>
<RefreshKeysDialog open onOpenChange={vi.fn()} /> <RefreshKeysDialog open onOpenChange={vi.fn()} />
</DeviceContext.Provider> </DeviceContext.Provider>,
); );
expect(screen.getByText(/Keys Mismatch - Problem Node Long/)).toBeInTheDocument(); expect(screen.getByText(/Keys Mismatch - Problem Node Long/))
expect(screen.getByText(/Your node is unable to send a direct message to node: Problem Node Long \(ProbNode\)/)).toBeInTheDocument(); .toBeInTheDocument();
expect(screen.getByRole("button", { name: "Request New Keys" })).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(); expect(screen.getByRole("button", { name: "Dismiss" })).toBeInTheDocument();
}); });
@ -90,7 +100,7 @@ test("does not render dialog if no error exists for active chat", () => {
const { container } = render( const { container } = render(
<DeviceContext.Provider value={currentDeviceState}> <DeviceContext.Provider value={currentDeviceState}>
<RefreshKeysDialog open onOpenChange={vi.fn()} /> <RefreshKeysDialog open onOpenChange={vi.fn()} />
</DeviceContext.Provider> </DeviceContext.Provider>,
); );
expect(container.firstChild).toBeNull(); expect(container.firstChild).toBeNull();

19
src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx

@ -16,7 +16,9 @@ export interface RefreshKeysDialogProps {
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
} }
export const RefreshKeysDialog = ({ open, onOpenChange }: RefreshKeysDialogProps) => { export const RefreshKeysDialog = (
{ open, onOpenChange }: RefreshKeysDialogProps,
) => {
const { activeChat } = useMessageStore(); const { activeChat } = useMessageStore();
const { nodeErrors, getNode } = useDevice(); const { nodeErrors, getNode } = useDevice();
const { handleCloseDialog, handleNodeRemove } = useRefreshKeysDialog(); const { handleCloseDialog, handleNodeRemove } = useRefreshKeysDialog();
@ -31,8 +33,12 @@ export const RefreshKeysDialog = ({ open, onOpenChange }: RefreshKeysDialogProps
const text = { const text = {
title: `Keys Mismatch - ${nodeWithError?.user?.longName ?? ""}`, 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.`, description: `Your node is unable to send a direct message to node: ${
} nodeWithError?.user?.longName ?? ""
} (${
nodeWithError?.user?.shortName ?? ""
}). This is due to the remote node's current public key does not match the previously stored key for this node.`,
};
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-8 flex flex-col gap-2"> <DialogContent className="max-w-8 flex flex-col gap-2">
@ -44,7 +50,10 @@ export const RefreshKeysDialog = ({ open, onOpenChange }: RefreshKeysDialogProps
<ul className="mt-2"> <ul className="mt-2">
<li className="flex place-items-center gap-2 items-start"> <li className="flex place-items-center gap-2 items-start">
<div className="p-2 bg-slate-500 rounded-lg mt-1"> <div className="p-2 bg-slate-500 rounded-lg mt-1">
<LockKeyholeOpenIcon size={30} className="text-white justify-center" /> <LockKeyholeOpenIcon
size={30}
className="text-white justify-center"
/>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div> <div>
@ -70,6 +79,6 @@ export const RefreshKeysDialog = ({ open, onOpenChange }: RefreshKeysDialogProps
</ul> </ul>
{/* </DialogDescription> */} {/* </DialogDescription> */}
</DialogContent> </DialogContent>
</Dialog > </Dialog>
); );
}; };

14
src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.test.ts

@ -1,4 +1,4 @@
import { renderHook, act } from "@testing-library/react"; import { act, renderHook } from "@testing-library/react";
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts"; import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
import { beforeEach, describe, expect, it, Mock, vi } from "vitest"; import { beforeEach, describe, expect, it, Mock, vi } from "vitest";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
@ -38,7 +38,7 @@ describe("useRefreshKeysDialog Hook", () => {
}); });
vi.mocked(useMessageStore).mockReturnValue({ vi.mocked(useMessageStore).mockReturnValue({
activeChat: "chat-123" activeChat: "chat-123",
}); });
}); });
@ -46,7 +46,9 @@ describe("useRefreshKeysDialog Hook", () => {
getNodeErrorMock.mockReturnValue({ node: "node-abc" }); getNodeErrorMock.mockReturnValue({ node: "node-abc" });
const { result } = renderHook(() => useRefreshKeysDialog()); const { result } = renderHook(() => useRefreshKeysDialog());
act(() => { result.current.handleNodeRemove(); }); act(() => {
result.current.handleNodeRemove();
});
expect(getNodeErrorMock).toHaveBeenCalledTimes(1); expect(getNodeErrorMock).toHaveBeenCalledTimes(1);
expect(getNodeErrorMock).toHaveBeenCalledWith("chat-123"); expect(getNodeErrorMock).toHaveBeenCalledWith("chat-123");
@ -60,7 +62,9 @@ describe("useRefreshKeysDialog Hook", () => {
it("handleNodeRemove should do nothing if there is no error", () => { it("handleNodeRemove should do nothing if there is no error", () => {
const { result } = renderHook(() => useRefreshKeysDialog()); const { result } = renderHook(() => useRefreshKeysDialog());
act(() => { result.current.handleNodeRemove(); }); act(() => {
result.current.handleNodeRemove();
});
expect(getNodeErrorMock).toHaveBeenCalledTimes(1); expect(getNodeErrorMock).toHaveBeenCalledTimes(1);
expect(getNodeErrorMock).toHaveBeenCalledWith("chat-123"); expect(getNodeErrorMock).toHaveBeenCalledWith("chat-123");
@ -79,4 +83,4 @@ describe("useRefreshKeysDialog Hook", () => {
expect(setDialogOpenMock).toHaveBeenCalledTimes(1); expect(setDialogOpenMock).toHaveBeenCalledTimes(1);
expect(setDialogOpenMock).toHaveBeenCalledWith("refreshKeys", false); expect(setDialogOpenMock).toHaveBeenCalledWith("refreshKeys", false);
}); });
}); });

9
src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.ts

@ -3,11 +3,12 @@ import { useDevice } from "@core/stores/deviceStore.ts";
import { useMessageStore } from "@core/stores/messageStore/index.ts"; import { useMessageStore } from "@core/stores/messageStore/index.ts";
export function useRefreshKeysDialog() { export function useRefreshKeysDialog() {
const { removeNode, setDialogOpen, clearNodeError, getNodeError } = useDevice(); const { removeNode, setDialogOpen, clearNodeError, getNodeError } =
useDevice();
const { activeChat } = useMessageStore(); const { activeChat } = useMessageStore();
const handleCloseDialog = useCallback(() => { const handleCloseDialog = useCallback(() => {
setDialogOpen('refreshKeys', false); setDialogOpen("refreshKeys", false);
}, [setDialogOpen]); }, [setDialogOpen]);
const handleNodeRemove = useCallback(() => { const handleNodeRemove = useCallback(() => {
@ -22,6 +23,6 @@ export function useRefreshKeysDialog() {
return { return {
handleCloseDialog, handleCloseDialog,
handleNodeRemove handleNodeRemove,
}; };
} }

4
src/components/Dialog/TracerouteResponseDialog.tsx

@ -26,8 +26,8 @@ export const TracerouteResponseDialog = ({
const { getNode } = useDevice(); const { getNode } = useDevice();
const route: number[] = traceroute?.data.route ?? []; const route: number[] = traceroute?.data.route ?? [];
const routeBack: number[] = traceroute?.data.routeBack ?? []; const routeBack: number[] = traceroute?.data.routeBack ?? [];
const snrTowards = (traceroute?.data.snrTowards ?? []).map(snr => snr / 4); const snrTowards = (traceroute?.data.snrTowards ?? []).map((snr) => snr / 4);
const snrBack = (traceroute?.data.snrBack ?? []).map(snr => snr / 4); const snrBack = (traceroute?.data.snrBack ?? []).map((snr) => snr / 4);
const from = getNode(traceroute?.from ?? 0); const from = getNode(traceroute?.from ?? 0);
const longName = from?.user?.longName ?? const longName = from?.user?.longName ??
(from ? `!${numberToHexUnpadded(from?.num)}` : "Unknown"); (from ? `!${numberToHexUnpadded(from?.num)}` : "Unknown");

73
src/components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.test.tsx

@ -1,6 +1,6 @@
// deno-lint-ignore-file // deno-lint-ignore-file
import { render, screen, fireEvent } from "@testing-library/react"; import { fireEvent, render, screen } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx"; import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx";
import { eventBus } from "@core/utils/eventBus.ts"; import { eventBus } from "@core/utils/eventBus.ts";
import { DeviceWrapper } from "@app/DeviceWrapper.tsx"; import { DeviceWrapper } from "@app/DeviceWrapper.tsx";
@ -10,41 +10,58 @@ describe("UnsafeRolesDialog", () => {
setDialogOpen: vi.fn(), setDialogOpen: vi.fn(),
}; };
const renderWithDeviceContext = (ui: any) => { const renderWithDeviceContext = (ui: React.ReactNode) => {
return render( return render(
<DeviceWrapper device={mockDevice}> <DeviceWrapper device={mockDevice}>
{ui} {ui}
</DeviceWrapper> </DeviceWrapper>,
); );
}; };
it("renders the dialog when open is true", () => { it("renders the dialog when open is true", () => {
renderWithDeviceContext(<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />); renderWithDeviceContext(
<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />,
);
const dialog = screen.getByRole('dialog'); const dialog = screen.getByRole("dialog");
expect(dialog).toBeInTheDocument(); expect(dialog).toBeInTheDocument();
expect(screen.getByText(/I have read the/i)).toBeInTheDocument(); expect(screen.getByText(/I have read the/i)).toBeInTheDocument();
expect(screen.getByText(/understand the implications/i)).toBeInTheDocument(); expect(screen.getByText(/understand the implications/i))
.toBeInTheDocument();
const links = screen.getAllByRole('link'); const links = screen.getAllByRole("link");
expect(links).toHaveLength(2); expect(links).toHaveLength(2);
expect(links[0]).toHaveTextContent('Device Role Documentation'); expect(links[0]).toHaveTextContent("Device Role Documentation");
expect(links[1]).toHaveTextContent('Choosing The Right Device Role'); expect(links[1]).toHaveTextContent("Choosing The Right Device Role");
}); });
it("displays the correct links", () => { it("displays the correct links", () => {
renderWithDeviceContext(<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />); renderWithDeviceContext(
<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />,
);
const docLink = screen.getByRole("link", { name: /Device Role Documentation/i }); const docLink = screen.getByRole("link", {
const blogLink = screen.getByRole("link", { name: /Choosing The Right Device Role/i }); name: /Device Role Documentation/i,
});
const blogLink = screen.getByRole("link", {
name: /Choosing The Right Device Role/i,
});
expect(docLink).toHaveAttribute("href", "https://meshtastic.org/docs/configuration/radio/device/"); expect(docLink).toHaveAttribute(
expect(blogLink).toHaveAttribute("href", "https://meshtastic.org/blog/choosing-the-right-device-role/"); "href",
"https://meshtastic.org/docs/configuration/radio/device/",
);
expect(blogLink).toHaveAttribute(
"href",
"https://meshtastic.org/blog/choosing-the-right-device-role/",
);
}); });
it("does not allow confirmation until checkbox is checked", () => { it("does not allow confirmation until checkbox is checked", () => {
renderWithDeviceContext(<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />); renderWithDeviceContext(
<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />,
);
const confirmButton = screen.getByRole("button", { name: /confirm/i }); const confirmButton = screen.getByRole("button", { name: /confirm/i });
@ -58,27 +75,37 @@ describe("UnsafeRolesDialog", () => {
it("emits the correct event when closing via close button", () => { it("emits the correct event when closing via close button", () => {
const eventSpy = vi.spyOn(eventBus, "emit"); const eventSpy = vi.spyOn(eventBus, "emit");
renderWithDeviceContext(<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />); renderWithDeviceContext(
<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />,
);
const dismissButton = screen.getByRole("button", { name: /close/i }); const dismissButton = screen.getByRole("button", { name: /close/i });
fireEvent.click(dismissButton); fireEvent.click(dismissButton);
expect(eventSpy).toHaveBeenCalledWith("dialog:unsafeRoles", { action: "dismiss" }); expect(eventSpy).toHaveBeenCalledWith("dialog:unsafeRoles", {
action: "dismiss",
});
}); });
it("emits the correct event when dismissing", () => { it("emits the correct event when dismissing", () => {
const eventSpy = vi.spyOn(eventBus, "emit"); const eventSpy = vi.spyOn(eventBus, "emit");
renderWithDeviceContext(<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />); renderWithDeviceContext(
<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />,
);
const dismissButton = screen.getByRole("button", { name: /dismiss/i }); const dismissButton = screen.getByRole("button", { name: /dismiss/i });
fireEvent.click(dismissButton); fireEvent.click(dismissButton);
expect(eventSpy).toHaveBeenCalledWith("dialog:unsafeRoles", { action: "dismiss" }); expect(eventSpy).toHaveBeenCalledWith("dialog:unsafeRoles", {
action: "dismiss",
});
}); });
it("emits the correct event when confirming", () => { it("emits the correct event when confirming", () => {
const eventSpy = vi.spyOn(eventBus, "emit"); const eventSpy = vi.spyOn(eventBus, "emit");
renderWithDeviceContext(<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />); renderWithDeviceContext(
<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />,
);
const checkbox = screen.getByRole("checkbox"); const checkbox = screen.getByRole("checkbox");
const confirmButton = screen.getByRole("button", { name: /confirm/i }); const confirmButton = screen.getByRole("button", { name: /confirm/i });
@ -86,6 +113,8 @@ describe("UnsafeRolesDialog", () => {
fireEvent.click(checkbox); fireEvent.click(checkbox);
fireEvent.click(confirmButton); fireEvent.click(confirmButton);
expect(eventSpy).toHaveBeenCalledWith("dialog:unsafeRoles", { action: "confirm" }); expect(eventSpy).toHaveBeenCalledWith("dialog:unsafeRoles", {
action: "confirm",
});
}); });
}); });

41
src/components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx

@ -19,29 +19,40 @@ export interface RouterRoleDialogProps {
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
} }
export const UnsafeRolesDialog = ({ open, onOpenChange }: RouterRoleDialogProps) => { export const UnsafeRolesDialog = (
{ open, onOpenChange }: RouterRoleDialogProps,
) => {
const [confirmState, setConfirmState] = useState(false); const [confirmState, setConfirmState] = useState(false);
const { setDialogOpen } = useDevice(); const { setDialogOpen } = useDevice();
const deviceRoleLink = "https://meshtastic.org/docs/configuration/radio/device/"; const deviceRoleLink =
const choosingTheRightDeviceRoleLink = "https://meshtastic.org/blog/choosing-the-right-device-role/"; "https://meshtastic.org/docs/configuration/radio/device/";
const choosingTheRightDeviceRoleLink =
"https://meshtastic.org/blog/choosing-the-right-device-role/";
const handleCloseDialog = (action: 'confirm' | 'dismiss') => { const handleCloseDialog = (action: "confirm" | "dismiss") => {
setDialogOpen('unsafeRoles', false); setDialogOpen("unsafeRoles", false);
setConfirmState(false); setConfirmState(false);
eventBus.emit('dialog:unsafeRoles', { action }); eventBus.emit("dialog:unsafeRoles", { action });
} };
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-8 flex flex-col"> <DialogContent className="max-w-8 flex flex-col">
<DialogClose onClick={() => handleCloseDialog('dismiss')} /> <DialogClose onClick={() => handleCloseDialog("dismiss")} />
<DialogHeader> <DialogHeader>
<DialogTitle>Are you sure?</DialogTitle> <DialogTitle>Are you sure?</DialogTitle>
</DialogHeader> </DialogHeader>
<DialogDescription className="text-md"> <DialogDescription className="text-md">
I have read the <Link href={deviceRoleLink} className="">Device Role Documentation</Link>{" "} I have read the{" "}
and the blog post about <Link href={choosingTheRightDeviceRoleLink}>Choosing The Right Device Role</Link> and understand the implications of changing the role. <Link href={deviceRoleLink} className="">
Device Role Documentation
</Link>{" "}
and the blog post about{" "}
<Link href={choosingTheRightDeviceRoleLink}>
Choosing The Right Device Role
</Link>{" "}
and understand the implications of changing the role.
</DialogDescription> </DialogDescription>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Checkbox <Checkbox
@ -56,16 +67,20 @@ export const UnsafeRolesDialog = ({ open, onOpenChange }: RouterRoleDialogProps)
<Button <Button
variant="default" variant="default"
name="dismiss" name="dismiss"
onClick={() => handleCloseDialog('dismiss')}> Dismiss onClick={() => handleCloseDialog("dismiss")}
>
Dismiss
</Button> </Button>
<Button <Button
variant="default" variant="default"
name="confirm" name="confirm"
disabled={!confirmState} disabled={!confirmState}
onClick={() => handleCloseDialog('confirm')}> Confirm onClick={() => handleCloseDialog("confirm")}
>
Confirm
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog > </Dialog>
); );
}; };

98
src/components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.test.tsx

@ -1,9 +1,12 @@
import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, Mock, vi } from "vitest";
import { renderHook } from '@testing-library/react'; import { renderHook } from "@testing-library/react";
import { useUnsafeRolesDialog, UNSAFE_ROLES } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts"; import {
UNSAFE_ROLES,
useUnsafeRolesDialog,
} from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
import { eventBus } from "@core/utils/eventBus.ts"; import { eventBus } from "@core/utils/eventBus.ts";
vi.mock('@core/utils/eventBus', () => ({ vi.mock("@core/utils/eventBus", () => ({
eventBus: { eventBus: {
on: vi.fn(), on: vi.fn(),
off: vi.fn(), off: vi.fn(),
@ -15,13 +18,13 @@ const mockDevice = {
setDialogOpen: vi.fn(), setDialogOpen: vi.fn(),
}; };
vi.mock('@core/stores/deviceStore', () => ({ vi.mock("@core/stores/deviceStore", () => ({
useDevice: () => ({ useDevice: () => ({
setDialogOpen: mockDevice.setDialogOpen, setDialogOpen: mockDevice.setDialogOpen,
}), }),
})); }));
describe('useUnsafeRolesDialog', () => { describe("useUnsafeRolesDialog", () => {
beforeEach(() => { beforeEach(() => {
vi.resetAllMocks(); vi.resetAllMocks();
}); });
@ -34,84 +37,115 @@ describe('useUnsafeRolesDialog', () => {
return renderHook(() => useUnsafeRolesDialog()); return renderHook(() => useUnsafeRolesDialog());
}; };
describe('handleCloseDialog', () => { describe("handleCloseDialog", () => {
it('should call setDialogOpen with correct parameters when dialog is closed', () => { it("should call setDialogOpen with correct parameters when dialog is closed", () => {
const { result } = renderUnsafeRolesHook(); const { result } = renderUnsafeRolesHook();
result.current.handleCloseDialog(); result.current.handleCloseDialog();
expect(mockDevice.setDialogOpen).toHaveBeenCalledWith('unsafeRoles', false); expect(mockDevice.setDialogOpen).toHaveBeenCalledWith(
"unsafeRoles",
false,
);
}); });
}); });
describe('validateRoleSelection', () => { describe("validateRoleSelection", () => {
it('should resolve with true for safe roles without opening dialog', async () => { it("should resolve with true for safe roles without opening dialog", async () => {
const { result } = renderUnsafeRolesHook(); const { result } = renderUnsafeRolesHook();
const safeRole = 'SAFE_ROLE'; const safeRole = "SAFE_ROLE";
const validationResult = await result.current.validateRoleSelection(safeRole); const validationResult = await result.current.validateRoleSelection(
safeRole,
);
expect(validationResult).toBe(true); expect(validationResult).toBe(true);
expect(mockDevice.setDialogOpen).not.toHaveBeenCalled(); expect(mockDevice.setDialogOpen).not.toHaveBeenCalled();
}); });
it('should open dialog for unsafe roles and resolve with true when confirmed', async () => { it("should open dialog for unsafe roles and resolve with true when confirmed", async () => {
const { result } = renderUnsafeRolesHook(); const { result } = renderUnsafeRolesHook();
const validationPromise = result.current.validateRoleSelection(UNSAFE_ROLES[0]); const validationPromise = result.current.validateRoleSelection(
UNSAFE_ROLES[0],
);
expect(mockDevice.setDialogOpen).toHaveBeenCalledWith('unsafeRoles', true); expect(mockDevice.setDialogOpen).toHaveBeenCalledWith(
expect(eventBus.on).toHaveBeenCalledWith('dialog:unsafeRoles', expect.any(Function)); "unsafeRoles",
true,
);
expect(eventBus.on).toHaveBeenCalledWith(
"dialog:unsafeRoles",
expect.any(Function),
);
const onHandler = (eventBus.on as Mock).mock.calls[0][1]; const onHandler = (eventBus.on as Mock).mock.calls[0][1];
onHandler({ action: 'confirm' }); onHandler({ action: "confirm" });
const validationResult = await validationPromise; const validationResult = await validationPromise;
expect(validationResult).toBe(true); expect(validationResult).toBe(true);
expect(eventBus.off).toHaveBeenCalledWith('dialog:unsafeRoles', onHandler); expect(eventBus.off).toHaveBeenCalledWith(
"dialog:unsafeRoles",
onHandler,
);
}); });
it('should resolve with false when user dismisses the dialog', async () => { it("should resolve with false when user dismisses the dialog", async () => {
const { result } = renderUnsafeRolesHook(); const { result } = renderUnsafeRolesHook();
const validationPromise = result.current.validateRoleSelection(UNSAFE_ROLES[0]); const validationPromise = result.current.validateRoleSelection(
UNSAFE_ROLES[0],
);
const onHandler = (eventBus.on as Mock).mock.calls[0][1]; const onHandler = (eventBus.on as Mock).mock.calls[0][1];
onHandler({ action: 'dismiss' }); onHandler({ action: "dismiss" });
const validationResult = await validationPromise; const validationResult = await validationPromise;
expect(validationResult).toBe(false); expect(validationResult).toBe(false);
expect(eventBus.off).toHaveBeenCalledWith('dialog:unsafeRoles', onHandler); expect(eventBus.off).toHaveBeenCalledWith(
"dialog:unsafeRoles",
onHandler,
);
}); });
it('should clean up event listener after response', async () => { it("should clean up event listener after response", async () => {
const { result } = renderUnsafeRolesHook(); const { result } = renderUnsafeRolesHook();
const validationPromise = result.current.validateRoleSelection(UNSAFE_ROLES[1]); const validationPromise = result.current.validateRoleSelection(
UNSAFE_ROLES[1],
);
const onHandler = (eventBus.on as Mock).mock.calls[0][1]; const onHandler = (eventBus.on as Mock).mock.calls[0][1];
onHandler({ action: 'confirm' }); onHandler({ action: "confirm" });
await validationPromise; await validationPromise;
expect(eventBus.off).toHaveBeenCalledWith('dialog:unsafeRoles', onHandler); expect(eventBus.off).toHaveBeenCalledWith(
"dialog:unsafeRoles",
onHandler,
);
}); });
}); });
it('should work with all unsafe roles', async () => { it("should work with all unsafe roles", async () => {
const { result } = renderUnsafeRolesHook(); const { result } = renderUnsafeRolesHook();
for (const unsafeRole of UNSAFE_ROLES) { for (const unsafeRole of UNSAFE_ROLES) {
mockDevice.setDialogOpen.mockClear(); mockDevice.setDialogOpen.mockClear();
(eventBus.on as Mock).mockClear(); (eventBus.on as Mock).mockClear();
const validationPromise = result.current.validateRoleSelection(unsafeRole); const validationPromise = result.current.validateRoleSelection(
unsafeRole,
);
expect(mockDevice.setDialogOpen).toHaveBeenCalledWith('unsafeRoles', true); expect(mockDevice.setDialogOpen).toHaveBeenCalledWith(
"unsafeRoles",
true,
);
const onHandler = (eventBus.on as Mock).mock.calls[0][1]; const onHandler = (eventBus.on as Mock).mock.calls[0][1];
onHandler({ action: 'confirm' }); onHandler({ action: "confirm" });
const validationResult = await validationPromise; const validationResult = await validationPromise;
expect(validationResult).toBe(true); expect(validationResult).toBe(true);
} }
}); });
}); });

6
src/components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts

@ -21,7 +21,9 @@ export const useUnsafeRolesDialog = () => {
setDialogOpen("unsafeRoles", true); setDialogOpen("unsafeRoles", true);
return new Promise((resolve) => { return new Promise((resolve) => {
const handleResponse = ({ action }: { action: "confirm" | "dismiss" }) => { const handleResponse = (
{ action }: { action: "confirm" | "dismiss" },
) => {
eventBus.off("dialog:unsafeRoles", handleResponse); eventBus.off("dialog:unsafeRoles", handleResponse);
resolve(action === "confirm"); resolve(action === "confirm");
}; };
@ -29,7 +31,7 @@ export const useUnsafeRolesDialog = () => {
eventBus.on("dialog:unsafeRoles", handleResponse); eventBus.on("dialog:unsafeRoles", handleResponse);
}); });
}, },
[setDialogOpen] [setDialogOpen],
); );
return { return {

5
src/components/Form/DynamicForm.tsx

@ -15,7 +15,6 @@ import {
} from "react-hook-form"; } from "react-hook-form";
import { Heading } from "@components/UI/Typography/Heading.tsx"; import { Heading } from "@components/UI/Typography/Heading.tsx";
interface DisabledBy<T> { interface DisabledBy<T> {
fieldName: Path<T>; fieldName: Path<T>;
selector?: number; selector?: number;
@ -124,7 +123,9 @@ export function DynamicForm<T extends FieldValues>({
})} })}
</div> </div>
))} ))}
{hasSubmitButton && <Button type="submit" variant="outline">Submit</Button>} {hasSubmitButton && (
<Button type="submit" variant="outline">Submit</Button>
)}
</form> </form>
); );
} }

28
src/components/Form/FormInput.tsx

@ -5,8 +5,7 @@ import type {
import { Input } from "@components/UI/Input.tsx"; import { Input } from "@components/UI/Input.tsx";
import type { ChangeEventHandler } from "react"; import type { ChangeEventHandler } from "react";
import { useState } from "react"; import { useState } from "react";
import { useController, type FieldValues } from "react-hook-form"; import { type FieldValues, useController } from "react-hook-form";
import { cn } from "@core/utils/cn.ts";
export interface InputFieldProps<T> extends BaseFormBuilderProps<T> { export interface InputFieldProps<T> extends BaseFormBuilderProps<T> {
type: "text" | "number" | "password"; type: "text" | "number" | "password";
@ -22,7 +21,7 @@ export interface InputFieldProps<T> extends BaseFormBuilderProps<T> {
max?: number; max?: number;
currentValueLength?: number; currentValueLength?: number;
showCharacterCount?: boolean; showCharacterCount?: boolean;
}, };
showPasswordToggle?: boolean; showPasswordToggle?: boolean;
showCopyButton?: boolean; showCopyButton?: boolean;
}; };
@ -34,7 +33,9 @@ export function GenericInput<T extends FieldValues>({
field, field,
}: GenericFormElementProps<T, InputFieldProps<T>>) { }: GenericFormElementProps<T, InputFieldProps<T>>) {
const { fieldLength, ...restProperties } = field.properties || {}; const { fieldLength, ...restProperties } = field.properties || {};
const [currentLength, setCurrentLength] = useState<number>(fieldLength?.currentValueLength || 0); const [currentLength, setCurrentLength] = useState<number>(
fieldLength?.currentValueLength || 0,
);
const { field: controllerField } = useController({ const { field: controllerField } = useController({
name: field.name, name: field.name,
@ -44,27 +45,36 @@ export function GenericInput<T extends FieldValues>({
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value; const newValue = e.target.value;
if (field.properties?.fieldLength?.max && newValue.length > field.properties?.fieldLength?.max) { if (
field.properties?.fieldLength?.max &&
newValue.length > field.properties?.fieldLength?.max
) {
return; return;
} }
setCurrentLength(newValue.length); setCurrentLength(newValue.length);
if (field.inputChange) field.inputChange(e); if (field.inputChange) field.inputChange(e);
controllerField.onChange(field.type === "number" ? Number.parseFloat(newValue).toString() : newValue); controllerField.onChange(
field.type === "number"
? Number.parseFloat(newValue).toString()
: newValue,
);
}; };
return ( return (
<div className="relative w-full"> <div className="relative w-full">
<Input <Input
type={field.type} type={field.type}
step={field.properties?.step} step={field.properties?.step}
value={field.type === "number" ? String(controllerField.value) : controllerField.value} value={field.type === "number"
? String(controllerField.value)
: controllerField.value}
id={field.name} id={field.name}
onChange={handleInputChange} onChange={handleInputChange}
showCopyButton={field.properties?.showCopyButton} showCopyButton={field.properties?.showCopyButton}
showPasswordToggle={field.properties?.showPasswordToggle || field.type === "password"} showPasswordToggle={field.properties?.showPasswordToggle ||
field.type === "password"}
className={field.properties?.className} className={field.properties?.className}
{...restProperties} {...restProperties}
disabled={disabled} disabled={disabled}

2
src/components/Form/FormMultiSelect.tsx

@ -61,4 +61,4 @@ export function MultiSelectInput<T extends FieldValues>({
))} ))}
</MultiSelect> </MultiSelect>
); );
} }

2
src/components/Form/FormPasswordGenerator.tsx

@ -31,7 +31,7 @@ export function PasswordGenerator<T extends FieldValues>({
field, field,
disabled, disabled,
}: GenericFormElementProps<T, PasswordGeneratorProps<T>>) { }: GenericFormElementProps<T, PasswordGeneratorProps<T>>) {
const { isVisible } = usePasswordVisibilityToggle() const { isVisible } = usePasswordVisibilityToggle();
return ( return (
<Controller <Controller

5
src/components/Form/FormSelect.tsx

@ -9,7 +9,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@components/UI/Select.tsx"; } from "@components/UI/Select.tsx";
import { useController, type FieldValues } from "react-hook-form"; import { type FieldValues, useController } from "react-hook-form";
export interface SelectFieldProps<T> extends BaseFormBuilderProps<T> { export interface SelectFieldProps<T> extends BaseFormBuilderProps<T> {
type: "select"; type: "select";
@ -46,7 +46,8 @@ export function SelectInput<T extends FieldValues>({
control, control,
}); });
const { enumValue, formatEnumName, ...remainingProperties } = field.properties; const { enumValue, formatEnumName, ...remainingProperties } =
field.properties;
const valueToKeyMap: Record<string, string> = {}; const valueToKeyMap: Record<string, string> = {};
const optionsEnumValues: [string, number][] = []; const optionsEnumValues: [string, number][] = [];

4
src/components/Form/FormWrapper.tsx

@ -24,7 +24,9 @@ export const FieldWrapper = ({
<div className="grid grid-cols-1 lg:grid-cols-[0.6fr_2fr_.1fr] sm:items-baseline gap-4"> <div className="grid grid-cols-1 lg:grid-cols-[0.6fr_2fr_.1fr] sm:items-baseline gap-4">
<Label htmlFor={fieldName}>{label}</Label> <Label htmlFor={fieldName}>{label}</Label>
<div className="max-w-3xl"> <div className="max-w-3xl">
<p className="text-sm text-slate-500 dark:text-slate-400">{description}</p> <p className="text-sm text-slate-500 dark:text-slate-400">
{description}
</p>
<p hidden={valid ?? true} className="text-sm text-red-500"> <p hidden={valid ?? true} className="text-sm text-red-500">
{validationText} {validationText}
</p> </p>

13
src/components/PageComponents/Channel.tsx

@ -99,8 +99,12 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
psk: pass, psk: pass,
moduleSettings: { moduleSettings: {
...channel?.settings?.moduleSettings, ...channel?.settings?.moduleSettings,
positionPrecision: channel?.settings?.moduleSettings?.positionPrecision === undefined ? 10 : channel?.settings?.moduleSettings?.positionPrecision, positionPrecision:
} channel?.settings?.moduleSettings?.positionPrecision ===
undefined
? 10
: channel?.settings?.moduleSettings?.positionPrecision,
},
}, },
}, },
}} }}
@ -125,7 +129,7 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
{ {
type: "passwordGenerator", type: "passwordGenerator",
name: "settings.psk", name: "settings.psk",
id: 'channel-psk', id: "channel-psk",
label: "Pre-Shared Key", label: "Pre-Shared Key",
description: description:
"Supported PSK lengths: 256-bit, 128-bit, 8-bit, Empty (0-bit)", "Supported PSK lengths: 256-bit, 128-bit, 8-bit, Empty (0-bit)",
@ -212,7 +216,8 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
text={{ text={{
button: "Regenerate", button: "Regenerate",
title: "Regenerate Pre-Shared Key?", title: "Regenerate Pre-Shared Key?",
description: "Are you sure you want to regenerate the pre-shared key?", description:
"Are you sure you want to regenerate the pre-shared key?",
}} }}
open={preSharedDialogOpen} open={preSharedDialogOpen}
onOpenChange={() => setPreSharedDialogOpen(false)} onOpenChange={() => setPreSharedDialogOpen(false)}

66
src/components/PageComponents/Config/Device/Device.test.tsx

@ -1,21 +1,21 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { Device } from '@components/PageComponents/Config/Device/index.tsx'; import { Device } from "@components/PageComponents/Config/Device/index.tsx";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import { useUnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts"; import { useUnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
import { Protobuf } from "@meshtastic/core"; import { Protobuf } from "@meshtastic/core";
vi.mock('@core/stores/deviceStore.ts', () => ({ vi.mock("@core/stores/deviceStore.ts", () => ({
useDevice: vi.fn() useDevice: vi.fn(),
})); }));
vi.mock('@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts', () => ({ vi.mock("@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts", () => ({
useUnsafeRolesDialog: vi.fn() useUnsafeRolesDialog: vi.fn(),
})); }));
// Mock the DynamicForm component since we're testing the Device component, // Mock the DynamicForm component since we're testing the Device component,
// not the DynamicForm implementation // not the DynamicForm implementation
vi.mock('@components/Form/DynamicForm', () => ({ vi.mock("@components/Form/DynamicForm", () => ({
DynamicForm: vi.fn(({ onSubmit }) => { DynamicForm: vi.fn(({ onSubmit }) => {
// Render a simplified version of the form for testing // Render a simplified version of the form for testing
return ( return (
@ -28,13 +28,16 @@ vi.mock('@components/Form/DynamicForm', () => ({
onSubmit(mockData); onSubmit(mockData);
}} }}
> >
{Object.entries(Protobuf.Config.Config_DeviceConfig_Role).map(([key, value]) => ( {Object.entries(Protobuf.Config.Config_DeviceConfig_Role).map((
[key, value],
) => (
<option key={key} value={value}> <option key={key} value={value}>
{key} {key}
</option> </option>
))} ))}
</select> </select>
<button type="submit" <button
type="submit"
data-testid="submit-button" data-testid="submit-button"
onClick={() => onSubmit({ role: "CLIENT" })} onClick={() => onSubmit({ role: "CLIENT" })}
> >
@ -42,10 +45,10 @@ vi.mock('@components/Form/DynamicForm', () => ({
</button> </button>
</div> </div>
); );
}) }),
})); }));
describe('Device component', () => { describe("Device component", () => {
const setWorkingConfigMock = vi.fn(); const setWorkingConfigMock = vi.fn();
const validateRoleSelectionMock = vi.fn(); const validateRoleSelectionMock = vi.fn();
const mockDeviceConfig = { const mockDeviceConfig = {
@ -63,17 +66,17 @@ describe('Device component', () => {
vi.resetAllMocks(); vi.resetAllMocks();
// Mock the useDevice hook // Mock the useDevice hook
(useDevice as any).mockReturnValue({ useDevice.mockReturnValue({
config: { config: {
device: mockDeviceConfig device: mockDeviceConfig,
}, },
setWorkingConfig: setWorkingConfigMock setWorkingConfig: setWorkingConfigMock,
}); });
// Mock the useUnsafeRolesDialog hook // Mock the useUnsafeRolesDialog hook
validateRoleSelectionMock.mockResolvedValue(true); validateRoleSelectionMock.mockResolvedValue(true);
(useUnsafeRolesDialog as any).mockReturnValue({ useUnsafeRolesDialog.mockReturnValue({
validateRoleSelection: validateRoleSelectionMock validateRoleSelection: validateRoleSelectionMock,
}); });
}); });
@ -81,49 +84,48 @@ describe('Device component', () => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it('should render the Device form', () => { it("should render the Device form", () => {
render(<Device />); render(<Device />);
expect(screen.getByTestId('dynamic-form')).toBeInTheDocument(); expect(screen.getByTestId("dynamic-form")).toBeInTheDocument();
}); });
it('should use the validateRoleSelection from the unsafe roles hook', () => { it("should use the validateRoleSelection from the unsafe roles hook", () => {
render(<Device />); render(<Device />);
expect(useUnsafeRolesDialog).toHaveBeenCalled(); expect(useUnsafeRolesDialog).toHaveBeenCalled();
}); });
it('should call setWorkingConfig when form is submitted', async () => { it("should call setWorkingConfig when form is submitted", async () => {
render(<Device />); render(<Device />);
fireEvent.click(screen.getByTestId('submit-button')); fireEvent.click(screen.getByTestId("submit-button"));
await waitFor(() => { await waitFor(() => {
expect(setWorkingConfigMock).toHaveBeenCalledWith( expect(setWorkingConfigMock).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
payloadVariant: { payloadVariant: {
case: "device", case: "device",
value: expect.objectContaining({ role: "CLIENT" }) value: expect.objectContaining({ role: "CLIENT" }),
} },
}) }),
); );
}); });
}); });
it("should create config with proper structure", async () => {
it('should create config with proper structure', async () => {
render(<Device />); render(<Device />);
// Simulate form submission // Simulate form submission
fireEvent.click(screen.getByTestId('submit-button')); fireEvent.click(screen.getByTestId("submit-button"));
await waitFor(() => { await waitFor(() => {
expect(setWorkingConfigMock).toHaveBeenCalledWith( expect(setWorkingConfigMock).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
payloadVariant: { payloadVariant: {
case: "device", case: "device",
value: expect.any(Object) value: expect.any(Object),
} },
}) }),
); );
}); });
}); });
}); });

12
src/components/PageComponents/Config/Device/index.tsx

@ -16,7 +16,7 @@ export const Device = () => {
case: "device", case: "device",
value: data, value: data,
}, },
}) }),
); );
}; };
return ( return (
@ -83,16 +83,16 @@ export const Device = () => {
description: "Disable triple click", description: "Disable triple click",
}, },
{ {
type: 'text', type: "text",
name: 'tzdef', name: "tzdef",
label: 'POSIX Timezone', label: "POSIX Timezone",
description: 'The POSIX timezone string for the device', description: "The POSIX timezone string for the device",
properties: { properties: {
fieldLength: { fieldLength: {
max: 64, max: 64,
currentValueLength: config.device?.tzdef?.length, currentValueLength: config.device?.tzdef?.length,
showCharacterCount: true, showCharacterCount: true,
} },
}, },
}, },
{ {

190
src/components/PageComponents/Config/Network/Network.test.tsx

@ -1,131 +1,24 @@
// import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
// import { Network } from '@components/PageComponents/Config/Network/index.tsx'; import { Network } from "@components/PageComponents/Config/Network/index.tsx";
// import { useDevice } from "@core/stores/deviceStore.ts";
// import { Protobuf } from "@meshtastic/core";
// vi.mock('@core/stores/deviceStore', () => ({
// useDevice: vi.fn()
// }));
// vi.mock('@components/Form/DynamicForm', () => ({
// DynamicForm: vi.fn(({ onSubmit }) => {
// return (
// <div data-testid="dynamic-form">
// <select
// data-testid="role-select"
// onChange={(e) => {
// const mockData = { role: e.target.value };
// onSubmit(mockData);
// }}
// >
// {Object.entries(Protobuf.Config.Config_DeviceConfig_Role).map(([key, value]) => (
// <option key={key} value={value}>
// {key}
// </option>
// ))}
// </select>
// <button type="submit"
// data-testid="submit-button"
// onClick={() => onSubmit({ role: "CLIENT" })}
// >
// Submit
// </button>
// </div>
// );
// })
// }));
// describe('Network component', () => {
// const setWorkingConfigMock = vi.fn();
// const mockDeviceConfig = {
// role: "CLIENT",
// buttonGpio: 0,
// buzzerGpio: 0,
// rebroadcastMode: "ALL",
// nodeInfoBroadcastSecs: 300,
// doubleTapAsButtonPress: false,
// disableTripleClick: false,
// ledHeartbeatDisabled: false,
// };
// beforeEach(() => {
// vi.resetAllMocks();
// (useDevice as any).mockReturnValue({
// config: {
// device: mockDeviceConfig
// },
// setWorkingConfig: setWorkingConfigMock
// });
// });
// afterEach(() => {
// vi.clearAllMocks();
// });
// it('should render the Network form', () => {
// render(<Network />);
// expect(screen.getByTestId('dynamic-form')).toBeInTheDocument();
// });
// it('should call setWorkingConfig when form is submitted', async () => {
// render(<Network />);
// fireEvent.click(screen.getByTestId('submit-button'));
// await waitFor(() => {
// expect(setWorkingConfigMock).toHaveBeenCalledWith(
// expect.objectContaining({
// payloadVariant: {
// case: "device",
// value: expect.objectContaining({ role: "CLIENT" })
// }
// })
// );
// });
// });
// it('should create config with proper structure', async () => {
// render(<Network />);
// // Simulate form submission
// fireEvent.click(screen.getByTestId('submit-button'));
// await waitFor(() => {
// expect(setWorkingConfigMock).toHaveBeenCalledWith(
// expect.objectContaining({
// payloadVariant: {
// case: "network",
// value: expect.any(Object)
// }
// })
// );
// });
// });
// });
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { Network } from '@components/PageComponents/Config/Network/index.tsx';
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core"; import { Protobuf } from "@meshtastic/core";
vi.mock('@core/stores/deviceStore', () => ({ vi.mock("@core/stores/deviceStore", () => ({
useDevice: vi.fn() useDevice: vi.fn(),
})); }));
vi.mock('@components/Form/DynamicForm', async () => { vi.mock("@components/Form/DynamicForm", async () => {
const React = await import('react'); const React = await import("react");
const { useState } = React; const { useState } = React;
return { return {
DynamicForm: ({ onSubmit, defaultValues }: any) => { DynamicForm: ({ onSubmit, defaultValues }) => {
const [wifiEnabled, setWifiEnabled] = useState(defaultValues.wifiEnabled ?? false); const [wifiEnabled, setWifiEnabled] = useState(
const [ssid, setSsid] = useState(defaultValues.wifiSsid ?? ''); defaultValues.wifiEnabled ?? false,
const [psk, setPsk] = useState(defaultValues.wifiPsk ?? ''); );
const [ssid, setSsid] = useState(defaultValues.wifiSsid ?? "");
const [psk, setPsk] = useState(defaultValues.wifiPsk ?? "");
return ( return (
<form <form
@ -166,15 +59,14 @@ vi.mock('@components/Form/DynamicForm', async () => {
}, },
}; };
}); });
;
describe('Network component', () => { describe("Network component", () => {
const setWorkingConfigMock = vi.fn(); const setWorkingConfigMock = vi.fn();
const mockNetworkConfig = { const mockNetworkConfig = {
wifiEnabled: false, wifiEnabled: false,
wifiSsid: '', wifiSsid: "",
wifiPsk: '', wifiPsk: "",
ntpServer: '', ntpServer: "",
ethEnabled: false, ethEnabled: false,
addressMode: Protobuf.Config.Config_NetworkConfig_AddressMode.DHCP, addressMode: Protobuf.Config.Config_NetworkConfig_AddressMode.DHCP,
ipv4Config: { ipv4Config: {
@ -185,17 +77,17 @@ describe('Network component', () => {
}, },
enabledProtocols: enabledProtocols:
Protobuf.Config.Config_NetworkConfig_ProtocolFlags.NO_BROADCAST, Protobuf.Config.Config_NetworkConfig_ProtocolFlags.NO_BROADCAST,
rsyslogServer: '', rsyslogServer: "",
}; };
beforeEach(() => { beforeEach(() => {
vi.resetAllMocks(); vi.resetAllMocks();
(useDevice as any).mockReturnValue({ useDevice.mockReturnValue({
config: { config: {
network: mockNetworkConfig network: mockNetworkConfig,
}, },
setWorkingConfig: setWorkingConfigMock setWorkingConfig: setWorkingConfigMock,
}); });
}); });
@ -203,21 +95,21 @@ describe('Network component', () => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it('should render the Network form', () => { it("should render the Network form", () => {
render(<Network />); render(<Network />);
expect(screen.getByTestId('dynamic-form')).toBeInTheDocument(); expect(screen.getByTestId("dynamic-form")).toBeInTheDocument();
}); });
it('should disable SSID and PSK fields when wifi is off', () => { it("should disable SSID and PSK fields when wifi is off", () => {
render(<Network />); render(<Network />);
expect(screen.getByLabelText("SSID")).toBeDisabled(); expect(screen.getByLabelText("SSID")).toBeDisabled();
expect(screen.getByLabelText("PSK")).toBeDisabled(); expect(screen.getByLabelText("PSK")).toBeDisabled();
}); });
it('should enable SSID and PSK when wifi is toggled on', async () => { it("should enable SSID and PSK when wifi is toggled on", async () => {
render(<Network />); render(<Network />);
const toggle = screen.getByLabelText("WiFi Enabled"); const toggle = screen.getByLabelText("WiFi Enabled");
screen.debug() screen.debug();
fireEvent.click(toggle); // turns wifiEnabled = true fireEvent.click(toggle); // turns wifiEnabled = true
@ -227,7 +119,7 @@ describe('Network component', () => {
}); });
}); });
it('should call setWorkingConfig with the right structure on submit', async () => { it("should call setWorkingConfig with the right structure on submit", async () => {
render(<Network />); render(<Network />);
fireEvent.click(screen.getByTestId("submit-button")); fireEvent.click(screen.getByTestId("submit-button"));
@ -239,28 +131,28 @@ describe('Network component', () => {
case: "network", case: "network",
value: expect.objectContaining({ value: expect.objectContaining({
wifiEnabled: false, wifiEnabled: false,
wifiSsid: '', wifiSsid: "",
wifiPsk: '', wifiPsk: "",
ntpServer: '', ntpServer: "",
ethEnabled: false, ethEnabled: false,
rsyslogServer: '', rsyslogServer: "",
}) }),
} },
}) }),
); );
}); });
}); });
it('should submit valid data after enabling wifi and entering SSID and PSK', async () => { it("should submit valid data after enabling wifi and entering SSID and PSK", async () => {
render(<Network />); render(<Network />);
fireEvent.click(screen.getByLabelText("WiFi Enabled")); fireEvent.click(screen.getByLabelText("WiFi Enabled"));
fireEvent.change(screen.getByLabelText("SSID"), { fireEvent.change(screen.getByLabelText("SSID"), {
target: { value: "MySSID" } target: { value: "MySSID" },
}); });
fireEvent.change(screen.getByLabelText("PSK"), { fireEvent.change(screen.getByLabelText("PSK"), {
target: { value: "MySecretPSK" } target: { value: "MySecretPSK" },
}); });
fireEvent.click(screen.getByTestId("submit-button")); fireEvent.click(screen.getByTestId("submit-button"));
@ -273,10 +165,10 @@ describe('Network component', () => {
value: expect.objectContaining({ value: expect.objectContaining({
wifiEnabled: true, wifiEnabled: true,
wifiSsid: "MySSID", wifiSsid: "MySSID",
wifiPsk: "MySecretPSK" wifiPsk: "MySecretPSK",
}) }),
} },
}) }),
); );
}); });
}); });

14
src/components/PageComponents/Config/Network/index.tsx

@ -1,4 +1,7 @@
import { NetworkValidationSchema, type NetworkValidation } from "@app/validation/config/network.ts"; import {
type NetworkValidation,
NetworkValidationSchema,
} from "@app/validation/config/network.ts";
import { create } from "@bufbuild/protobuf"; import { create } from "@bufbuild/protobuf";
import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
@ -55,8 +58,8 @@ export const Network = () => {
), ),
dns: convertIntToIpAddress(config.network?.ipv4Config?.dns ?? 0), dns: convertIntToIpAddress(config.network?.ipv4Config?.dns ?? 0),
}, },
enabledProtocols: config.network?.enabledProtocols ?? Protobuf.Config.Config_NetworkConfig_ProtocolFlags.NO_BROADCAST enabledProtocols: config.network?.enabledProtocols ??
Protobuf.Config.Config_NetworkConfig_ProtocolFlags.NO_BROADCAST,
}} }}
fieldGroups={[ fieldGroups={[
{ {
@ -183,10 +186,9 @@ export const Network = () => {
name: "enabledProtocols", name: "enabledProtocols",
label: "Mesh via UDP", label: "Mesh via UDP",
properties: { properties: {
enumValue: enumValue: Protobuf.Config.Config_NetworkConfig_ProtocolFlags,
Protobuf.Config.Config_NetworkConfig_ProtocolFlags,
formatEnumName: true, formatEnumName: true,
} },
}, },
], ],
}, },

2
src/components/PageComponents/Config/Position.tsx

@ -74,7 +74,7 @@ export const Position = () => {
name: "positionFlags", name: "positionFlags",
value: activeFlags, value: activeFlags,
isChecked: (name: string) => isChecked: (name: string) =>
activeFlags?.includes(name as FlagName) ?? false, activeFlags?.includes(name as FlagName) ?? false,
onValueChange: onPositonFlagChange, onValueChange: onPositonFlagChange,
label: "Position Flags", label: "Position Flags",
placeholder: "Select position flags...", placeholder: "Select position flags...",

14
src/components/PageComponents/Config/Security/Security.tsx

@ -1,16 +1,12 @@
import { PkiRegenerateDialog } from "@components/Dialog/PkiRegenerateDialog.tsx"; import { PkiRegenerateDialog } from "@components/Dialog/PkiRegenerateDialog.tsx";
import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import { useAppStore } from "@core/stores/appStore.ts"; import { useAppStore } from "@core/stores/appStore.ts";
import { import { getX25519PrivateKey, getX25519PublicKey } from "@core/utils/x25519.ts";
getX25519PrivateKey,
getX25519PublicKey,
} from "@core/utils/x25519.ts";
import type { SecurityValidation } from "@app/validation/config/security.ts"; import type { SecurityValidation } from "@app/validation/config/security.ts";
import { create } from "@bufbuild/protobuf"; import { create } from "@bufbuild/protobuf";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core"; import { Protobuf } from "@meshtastic/core";
import { fromByteArray, toByteArray } from "base64-js"; import { fromByteArray, toByteArray } from "base64-js";
import { Eye, EyeOff } from "lucide-react";
import { useReducer } from "react"; import { useReducer } from "react";
import { securityReducer } from "@components/PageComponents/Config/Security/securityReducer.tsx"; import { securityReducer } from "@components/PageComponents/Config/Security/securityReducer.tsx";
@ -58,7 +54,8 @@ export const Security = () => {
if (input.length % 4 !== 0) { if (input.length % 4 !== 0) {
addError( addError(
fieldName, fieldName,
`${fieldName === "privateKey" ? "Private" : "Admin" `${
fieldName === "privateKey" ? "Private" : "Admin"
} Key is required to be a 256 bit pre-shared key (PSK)`, } Key is required to be a 256 bit pre-shared key (PSK)`,
); );
return; return;
@ -73,7 +70,8 @@ export const Security = () => {
console.error(e); console.error(e);
addError( addError(
fieldName, fieldName,
`Invalid ${fieldName === "privateKey" ? "Private" : "Admin" `Invalid ${
fieldName === "privateKey" ? "Private" : "Admin"
} Key format`, } Key format`,
); );
} }
@ -242,7 +240,7 @@ export const Security = () => {
? getErrorMessage("adminKey") ? getErrorMessage("adminKey")
: "", : "",
inputChange: adminKeyInputChangeEvent, inputChange: adminKeyInputChangeEvent,
selectChange: () => { }, selectChange: () => {},
bits: [{ text: "256 bit", value: "32", key: "bit256" }], bits: [{ text: "256 bit", value: "32", key: "bit256" }],
devicePSKBitCount: state.privateKeyBitCount, devicePSKBitCount: state.privateKeyBitCount,
hide: !state.adminKeyVisible, hide: !state.adminKeyVisible,

10
src/components/PageComponents/Connect/BLE.tsx

@ -9,10 +9,12 @@ import { BleConnection, ServiceUuid } from "@meshtastic/js";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useMessageStore } from "../../../core/stores/messageStore/index.ts"; import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
export const BLE = ({ setConnectionInProgress, closeDialog }: TabElementProps) => { export const BLE = (
{ setConnectionInProgress, closeDialog }: TabElementProps,
) => {
const [bleDevices, setBleDevices] = useState<BluetoothDevice[]>([]); const [bleDevices, setBleDevices] = useState<BluetoothDevice[]>([]);
const { addDevice } = useDeviceStore(); const { addDevice } = useDeviceStore();
const messageStore = useMessageStore() const messageStore = useMessageStore();
const { setSelectedDevice } = useAppStore(); const { setSelectedDevice } = useAppStore();
const updateBleDeviceList = useCallback(async (): Promise<void> => { const updateBleDeviceList = useCallback(async (): Promise<void> => {
@ -59,8 +61,6 @@ export const BLE = ({ setConnectionInProgress, closeDialog }: TabElementProps) =
<Button <Button
variant="default" variant="default"
onClick={async () => { onClick={async () => {
await navigator.bluetooth await navigator.bluetooth
.requestDevice({ .requestDevice({
filters: [{ services: [ServiceUuid] }], filters: [{ services: [ServiceUuid] }],
@ -82,4 +82,4 @@ export const BLE = ({ setConnectionInProgress, closeDialog }: TabElementProps) =
</Button> </Button>
</div> </div>
); );
}; };

24
src/components/PageComponents/Connect/HTTP.test.tsx

@ -1,15 +1,17 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { HTTP } from "@components/PageComponents/Connect/HTTP.tsx"; import { HTTP } from "@components/PageComponents/Connect/HTTP.tsx";
import { MeshDevice } from "@meshtastic/core"; import { MeshDevice } from "@meshtastic/core";
import { TransportHTTP } from "@meshtastic/transport-http"; import { TransportHTTP } from "@meshtastic/transport-http";
import { vi, describe, it, expect } from "vitest"; import { describe, expect, it, vi } from "vitest";
vi.mock("@core/stores/appStore.ts", () => ({ vi.mock("@core/stores/appStore.ts", () => ({
useAppStore: vi.fn(() => ({ setSelectedDevice: vi.fn() })), useAppStore: vi.fn(() => ({ setSelectedDevice: vi.fn() })),
})); }));
vi.mock("@core/stores/deviceStore.ts", () => ({ vi.mock("@core/stores/deviceStore.ts", () => ({
useDeviceStore: vi.fn(() => ({ addDevice: vi.fn(() => ({ addConnection: vi.fn() })) })), useDeviceStore: vi.fn(() => ({
addDevice: vi.fn(() => ({ addConnection: vi.fn() })),
})),
})); }));
vi.mock("@core/utils/randId.ts", () => ({ vi.mock("@core/utils/randId.ts", () => ({
@ -28,13 +30,13 @@ vi.mock("@meshtastic/core", () => ({
})), })),
})); }));
describe("HTTP Component", () => { describe("HTTP Component", () => {
it("renders correctly", () => { it("renders correctly", () => {
render(<HTTP closeDialog={vi.fn()} />); render(<HTTP closeDialog={vi.fn()} />);
expect(screen.getByText("IP Address/Hostname")).toBeInTheDocument(); expect(screen.getByText("IP Address/Hostname")).toBeInTheDocument();
expect(screen.getByRole("textbox")).toBeInTheDocument(); expect(screen.getByRole("textbox")).toBeInTheDocument();
expect(screen.getByPlaceholderText("000.000.000.000 / meshtastic.local")).toBeInTheDocument(); expect(screen.getByPlaceholderText("000.000.000.000 / meshtastic.local"))
.toBeInTheDocument();
expect(screen.getByText("Use HTTPS")).toBeInTheDocument(); expect(screen.getByText("Use HTTPS")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Connect" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Connect" })).toBeInTheDocument();
}); });
@ -42,8 +44,9 @@ describe("HTTP Component", () => {
it("allows input field to be updated", () => { it("allows input field to be updated", () => {
render(<HTTP closeDialog={vi.fn()} />); render(<HTTP closeDialog={vi.fn()} />);
const inputField = screen.getByRole("textbox"); const inputField = screen.getByRole("textbox");
fireEvent.change(inputField, { target: { value: 'meshtastic.local' } }) fireEvent.change(inputField, { target: { value: "meshtastic.local" } });
expect(screen.getByPlaceholderText("000.000.000.000 / meshtastic.local")).toBeInTheDocument(); expect(screen.getByPlaceholderText("000.000.000.000 / meshtastic.local"))
.toBeInTheDocument();
}); });
it("toggles HTTPS switch and updates prefix", () => { it("toggles HTTPS switch and updates prefix", () => {
@ -52,10 +55,10 @@ describe("HTTP Component", () => {
const switchInput = screen.getByRole("switch"); const switchInput = screen.getByRole("switch");
expect(screen.getByText("http://")).toBeInTheDocument(); expect(screen.getByText("http://")).toBeInTheDocument();
fireEvent.click(switchInput) fireEvent.click(switchInput);
expect(screen.getByText("https://")).toBeInTheDocument(); expect(screen.getByText("https://")).toBeInTheDocument();
fireEvent.click(switchInput) fireEvent.click(switchInput);
expect(switchInput).not.toBeChecked(); expect(switchInput).not.toBeChecked();
expect(screen.getByText("http://")).toBeInTheDocument(); expect(screen.getByText("http://")).toBeInTheDocument();
}); });
@ -89,8 +92,7 @@ describe("HTTP Component", () => {
expect(MeshDevice).toBeCalled(); expect(MeshDevice).toBeCalled();
}); });
} catch (e) { } catch (e) {
console.error(e) console.error(e);
} }
}); });
}); });

39
src/components/PageComponents/Connect/HTTP.tsx

@ -11,7 +11,7 @@ import { randId } from "@core/utils/randId.ts";
import { MeshDevice } from "@meshtastic/core"; import { MeshDevice } from "@meshtastic/core";
import { TransportHTTP } from "@meshtastic/transport-http"; import { TransportHTTP } from "@meshtastic/transport-http";
import { useState } from "react"; import { useState } from "react";
import { useForm, useController } from "react-hook-form"; import { useController, useForm } from "react-hook-form";
import { AlertTriangle } from "lucide-react"; import { AlertTriangle } from "lucide-react";
import { useMessageStore } from "../../../core/stores/messageStore/index.ts"; import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
@ -20,7 +20,10 @@ interface FormData {
tls: boolean; tls: boolean;
} }
export const HTTP = ({ closeDialog, setConnectionInProgress, connectionInProgress }: TabElementProps) => { export const HTTP = (
{ closeDialog, setConnectionInProgress, connectionInProgress }:
TabElementProps,
) => {
const isURLHTTPS = location.protocol === "https:"; const isURLHTTPS = location.protocol === "https:";
const { addDevice } = useDeviceStore(); const { addDevice } = useDeviceStore();
@ -30,8 +33,8 @@ export const HTTP = ({ closeDialog, setConnectionInProgress, connectionInProgres
const { control, handleSubmit, register } = useForm<FormData>({ const { control, handleSubmit, register } = useForm<FormData>({
defaultValues: { defaultValues: {
ip: ["client.meshtastic.org", "localhost"].includes( ip: ["client.meshtastic.org", "localhost"].includes(
globalThis.location.hostname, globalThis.location.hostname,
) )
? "meshtastic.local" ? "meshtastic.local"
: globalThis.location.host, : globalThis.location.host,
tls: isURLHTTPS ? true : false, tls: isURLHTTPS ? true : false,
@ -42,7 +45,9 @@ export const HTTP = ({ closeDialog, setConnectionInProgress, connectionInProgres
field: { value: tlsValue, onChange: setTLS }, field: { value: tlsValue, onChange: setTLS },
} = useController({ name: "tls", control }); } = useController({ name: "tls", control });
const [connectionError, setConnectionError] = useState<{ host: string; secure: boolean } | null>(null); const [connectionError, setConnectionError] = useState<
{ host: string; secure: boolean } | null
>(null);
const onSubmit = handleSubmit(async (data) => { const onSubmit = handleSubmit(async (data) => {
setConnectionInProgress(true); setConnectionInProgress(true);
@ -91,21 +96,31 @@ export const HTTP = ({ closeDialog, setConnectionInProgress, connectionInProgres
{connectionError && ( {connectionError && (
<div className="mt-2 mb-2 p-3 rounded-md bg-amber-100 border border-amber-300 dark:bg-amber-100 dark:border-amber-300"> <div className="mt-2 mb-2 p-3 rounded-md bg-amber-100 border border-amber-300 dark:bg-amber-100 dark:border-amber-300">
<div className="flex gap-2 items-start"> <div className="flex gap-2 items-start">
<AlertTriangle className="shrink-0 mt-0.5 text-amber-600 dark:text-amber-600" size={20} /> <AlertTriangle
className="shrink-0 mt-0.5 text-amber-600 dark:text-amber-600"
size={20}
/>
<div> <div>
<p className="text-sm font-medium text-amber-800 dark:text-amber-800"> <p className="text-sm font-medium text-amber-800 dark:text-amber-800">
Connection Failed Connection Failed
</p> </p>
<p className="text-xs mt-1 text-amber-700 dark:text-amber-700"> <p className="text-xs mt-1 text-amber-700 dark:text-amber-700">
Could not connect to the device. {connectionError.secure && "If using HTTPS, you may need to accept a self-signed certificate first. "} Could not connect to the device. {connectionError.secure &&
"If using HTTPS, you may need to accept a self-signed certificate first. "}
Please open{" "} Please open{" "}
<Link <Link
href={`${connectionError.secure ? "https" : "http"}://${connectionError.host}`} href={`${
connectionError.secure ? "https" : "http"
}://${connectionError.host}`}
className="underline font-medium text-amber-800 dark:text-amber-800" className="underline font-medium text-amber-800 dark:text-amber-800"
> >
{`${connectionError.secure ? "https" : "http"}://${connectionError.host}`} {`${
connectionError.secure ? "https" : "http"
}://${connectionError.host}`}
</Link>{" "} </Link>{" "}
in a new tab{connectionError.secure ? ", accept any TLS warnings if prompted, then try again" : ""}.{" "} in a new tab{connectionError.secure
? ", accept any TLS warnings if prompted, then try again"
: ""}.{" "}
<Link <Link
href="https://meshtastic.org/docs/software/web-client/#http" href="https://meshtastic.org/docs/software/web-client/#http"
className="underline font-medium text-amber-800 dark:text-amber-800" className="underline font-medium text-amber-800 dark:text-amber-800"
@ -120,10 +135,10 @@ export const HTTP = ({ closeDialog, setConnectionInProgress, connectionInProgres
</div> </div>
<Button <Button
type="submit" type="submit"
variant={"default"} variant="default"
> >
<span>{connectionInProgress ? "Connecting..." : "Connect"}</span> <span>{connectionInProgress ? "Connecting..." : "Connect"}</span>
</Button> </Button>
</form> </form>
); );
}; };

11
src/components/PageComponents/Connect/Serial.tsx

@ -10,10 +10,12 @@ import { TransportWebSerial } from "@meshtastic/transport-web-serial";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useMessageStore } from "../../../core/stores/messageStore/index.ts"; import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
export const Serial = ({ setConnectionInProgress, closeDialog }: TabElementProps) => { export const Serial = (
{ setConnectionInProgress, closeDialog }: TabElementProps,
) => {
const [serialPorts, setSerialPorts] = useState<SerialPort[]>([]); const [serialPorts, setSerialPorts] = useState<SerialPort[]>([]);
const { addDevice } = useDeviceStore(); const { addDevice } = useDeviceStore();
const messageStore = useMessageStore() const messageStore = useMessageStore();
const { setSelectedDevice } = useAppStore(); const { setSelectedDevice } = useAppStore();
const updateSerialPortList = useCallback(async () => { const updateSerialPortList = useCallback(async () => {
@ -58,8 +60,9 @@ export const Serial = ({ setConnectionInProgress, closeDialog }: TabElementProps
await onConnect(port); await onConnect(port);
}} }}
> >
{`# ${index} - ${usbVendorId ?? "UNK"} - ${usbProductId ?? "UNK" {`# ${index} - ${usbVendorId ?? "UNK"} - ${
}`} usbProductId ?? "UNK"
}`}
</Button> </Button>
); );
})} })}

19
src/components/PageComponents/Map/NodeDetail.tsx

@ -23,7 +23,10 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@radix-ui/react-tooltip"; } from "@radix-ui/react-tooltip";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import { MessageType, useMessageStore } from "../../../core/stores/messageStore/index.ts"; import {
MessageType,
useMessageStore,
} from "../../../core/stores/messageStore/index.ts";
import BatteryStatus from "@components/BatteryStatus.tsx"; import BatteryStatus from "@components/BatteryStatus.tsx";
export interface NodeDetailProps { export interface NodeDetailProps {
@ -51,10 +54,12 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
<div className="flex flex-col items-center gap-2 min-w-6 pt-1"> <div className="flex flex-col items-center gap-2 min-w-6 pt-1">
<Avatar text={shortName} size="sm" /> <Avatar text={shortName} size="sm" />
<div onFocusCapture={(e) => { <div
// Required to prevent DM tooltip auto-appearing on creation onFocusCapture={(e) => {
e.stopPropagation(); // Required to prevent DM tooltip auto-appearing on creation
}}> e.stopPropagation();
}}
>
{node.user?.publicKey && node.user?.publicKey.length > 0 {node.user?.publicKey && node.user?.publicKey.length > 0
? ( ? (
<LockIcon <LockIcon
@ -177,7 +182,9 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
{!!node.deviceMetrics?.airUtilTx && ( {!!node.deviceMetrics?.airUtilTx && (
<div className="grow"> <div className="grow">
<div>Airtime Util</div> <div>Airtime Util</div>
<Mono className="text-gray-500">{node.deviceMetrics?.airUtilTx.toPrecision(3)}%</Mono> <Mono className="text-gray-500">
{node.deviceMetrics?.airUtilTx.toPrecision(3)}%
</Mono>
</div> </div>
)} )}
</div> </div>

20
src/components/PageComponents/Messages/ChannelChat.tsx

@ -19,7 +19,7 @@ export const ChannelChat = ({ messages = [] }: ChannelChatProps) => {
const scrollContainerRef = useRef<HTMLUListElement>(null); const scrollContainerRef = useRef<HTMLUListElement>(null);
const userScrolledUpRef = useRef(false); const userScrolledUpRef = useRef(false);
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => { const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
messagesEndRef.current?.scrollIntoView({ behavior }); messagesEndRef.current?.scrollIntoView({ behavior });
}); });
@ -28,10 +28,12 @@ export const ChannelChat = ({ messages = [] }: ChannelChatProps) => {
useEffect(() => { useEffect(() => {
const scrollContainer = scrollContainerRef.current; const scrollContainer = scrollContainerRef.current;
if (!scrollContainer) return; if (!scrollContainer) return;
const isScrolledToBottom = scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight <= 10; const isScrolledToBottom =
scrollContainer.scrollHeight - scrollContainer.scrollTop -
scrollContainer.clientHeight <= 10;
if (isScrolledToBottom || !userScrolledUpRef.current) { if (isScrolledToBottom || !userScrolledUpRef.current) {
scrollToBottom('smooth'); scrollToBottom("smooth");
} }
}, [messages, scrollToBottom]); }, [messages, scrollToBottom]);
@ -39,12 +41,16 @@ export const ChannelChat = ({ messages = [] }: ChannelChatProps) => {
const scrollContainer = scrollContainerRef.current; const scrollContainer = scrollContainerRef.current;
const handleScroll = () => { const handleScroll = () => {
if (!scrollContainer) return; if (!scrollContainer) return;
const isAtBottom = scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight <= 10; const isAtBottom =
scrollContainer.scrollHeight - scrollContainer.scrollTop -
scrollContainer.clientHeight <= 10;
userScrolledUpRef.current = !isAtBottom; userScrolledUpRef.current = !isAtBottom;
}; };
scrollContainer?.addEventListener('scroll', handleScroll, { passive: true }); scrollContainer?.addEventListener("scroll", handleScroll, {
passive: true,
});
return () => { return () => {
scrollContainer?.removeEventListener('scroll', handleScroll); scrollContainer?.removeEventListener("scroll", handleScroll);
}; };
}, []); }, []);
@ -74,4 +80,4 @@ export const ChannelChat = ({ messages = [] }: ChannelChatProps) => {
<div ref={messagesEndRef} className="h-px" /> <div ref={messagesEndRef} className="h-px" />
</ul> </ul>
); );
}; };

20
src/components/PageComponents/Messages/MessageActionsMenu.tsx

@ -6,7 +6,7 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@components/UI/Tooltip.tsx"; } from "@components/UI/Tooltip.tsx";
import { cn } from "@core/utils/cn.ts"; import { cn } from "@core/utils/cn.ts";
import { SmilePlus, Reply } from "lucide-react"; import { Reply, SmilePlus } from "lucide-react";
interface MessageActionsMenuProps { interface MessageActionsMenuProps {
onAddReaction?: () => void; onAddReaction?: () => void;
@ -15,7 +15,7 @@ interface MessageActionsMenuProps {
export const MessageActionsMenu = ({ export const MessageActionsMenu = ({
onAddReaction, onAddReaction,
onReply onReply,
}: MessageActionsMenuProps) => { }: MessageActionsMenuProps) => {
const hoverIconBarClass = cn( const hoverIconBarClass = cn(
"absolute top-2 right-2", "absolute top-2 right-2",
@ -25,7 +25,7 @@ export const MessageActionsMenu = ({
"rounded-md shadow-sm p-1", "rounded-md shadow-sm p-1",
"opacity-0 group-hover:opacity-100", "opacity-0 group-hover:opacity-100",
"transition-opacity duration-100 ease-in-out", "transition-opacity duration-100 ease-in-out",
"z-10" "z-10",
); );
const hoverIconButtonClass = cn( const hoverIconButtonClass = cn(
@ -33,13 +33,16 @@ export const MessageActionsMenu = ({
"text-gray-500 dark:text-gray-400", "text-gray-500 dark:text-gray-400",
"hover:text-gray-700 dark:hover:text-gray-300", "hover:text-gray-700 dark:hover:text-gray-300",
"hover:bg-gray-100 dark:hover:bg-zinc-700", "hover:bg-gray-100 dark:hover:bg-zinc-700",
"cursor-pointer" "cursor-pointer",
); );
const iconSizeClass = "size-4"; const iconSizeClass = "size-4";
return ( return (
<div className={cn(hoverIconBarClass)} onClick={(e) => e.stopPropagation()}> <div
className={cn(hoverIconBarClass)}
onClick={(e) => e.stopPropagation()}
>
<TooltipProvider delayDuration={300}> <TooltipProvider delayDuration={300}>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@ -47,7 +50,7 @@ export const MessageActionsMenu = ({
type="button" type="button"
aria-label="Add Reaction" aria-label="Add Reaction"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation();
if (onAddReaction) { if (onAddReaction) {
onAddReaction(); onAddReaction();
} }
@ -69,7 +72,7 @@ export const MessageActionsMenu = ({
type="button" type="button"
aria-label="Reply" aria-label="Reply"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation();
if (onReply) { if (onReply) {
onReply(); onReply();
} }
@ -85,7 +88,6 @@ export const MessageActionsMenu = ({
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
</div> </div>
); );
}; };

168
src/components/PageComponents/Messages/MessageInput.test.tsx

@ -1,18 +1,34 @@
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; import {
import { vi, describe, it, expect, beforeEach } from 'vitest'; act,
import { MessageInput, MessageInputProps } from './MessageInput.tsx'; fireEvent,
import { Types } from '@meshtastic/core'; render,
screen,
vi.mock('@components/UI/Button.tsx', () => ({ waitFor,
Button: vi.fn(({ type, className, children, onClick, onSubmit, variant, ...rest }) => ( } from "@testing-library/react";
<button type={type} className={className} onClick={onClick} onSubmit={onSubmit} {...rest}> import { beforeEach, describe, expect, it, vi } 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, ...rest },
) => (
<button
type={type}
className={className}
onClick={onClick}
onSubmit={onSubmit}
{...rest}
>
{children} {children}
</button> </button>
)), )),
})); }));
vi.mock('@components/UI/Input.tsx', () => ({ vi.mock("@components/UI/Input.tsx", () => ({
Input: vi.fn(({ autoFocus, minLength, name, placeholder, value, onChange }) => ( Input: vi.fn((
{ autoFocus, minLength, name, placeholder, value, onChange },
) => (
<input <input
autoFocus={autoFocus} autoFocus={autoFocus}
minLength={minLength} minLength={minLength}
@ -29,28 +45,28 @@ const mockSetDraft = vi.fn();
const mockGetDraft = vi.fn(); const mockGetDraft = vi.fn();
const mockClearDraft = vi.fn(); const mockClearDraft = vi.fn();
vi.mock('@core/stores/messageStore', () => ({ vi.mock("@core/stores/messageStore", () => ({
useMessageStore: vi.fn(() => ({ useMessageStore: vi.fn(() => ({
setDraft: mockSetDraft, setDraft: mockSetDraft,
getDraft: mockGetDraft, getDraft: mockGetDraft,
clearDraft: mockClearDraft, clearDraft: mockClearDraft,
})), })),
MessageState: { MessageState: {
Ack: 'ack', Ack: "ack",
Waiting: 'waiting', Waiting: "waiting",
Failed: 'failed', Failed: "failed",
}, },
MessageType: { MessageType: {
Direct: 'direct', Direct: "direct",
Broadcast: 'broadcast', Broadcast: "broadcast",
}, },
})); }));
vi.mock('lucide-react', () => ({ vi.mock("lucide-react", () => ({
SendIcon: vi.fn(() => <svg data-testid="send-icon" />), SendIcon: vi.fn(() => <svg data-testid="send-icon" />),
})); }));
describe('MessageInput', () => { describe("MessageInput", () => {
const mockOnSend = vi.fn(); const mockOnSend = vi.fn();
const defaultProps: MessageInputProps = { const defaultProps: MessageInputProps = {
onSend: mockOnSend, onSend: mockOnSend,
@ -61,56 +77,62 @@ describe('MessageInput', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mockGetDraft.mockReturnValue(''); mockGetDraft.mockReturnValue("");
}); });
const renderComponent = (props: Partial<MessageInputProps> = {}) => { const renderComponent = (props: Partial<MessageInputProps> = {}) => {
render(<MessageInput {...defaultProps} {...props} />); render(<MessageInput {...defaultProps} {...props} />);
}; };
it('should render the input field, byte counter, and send button', () => { it("should render the input field, byte counter, and send button", () => {
renderComponent(); renderComponent();
expect(screen.getByPlaceholderText('Enter Message')).toBeInTheDocument(); expect(screen.getByPlaceholderText("Enter Message")).toBeInTheDocument();
expect(screen.getByTestId('byte-counter')).toBeInTheDocument(); expect(screen.getByTestId("byte-counter")).toBeInTheDocument();
expect(screen.getByRole('button')).toBeInTheDocument(); expect(screen.getByRole("button")).toBeInTheDocument();
expect(screen.getByTestId('send-icon')).toBeInTheDocument(); expect(screen.getByTestId("send-icon")).toBeInTheDocument();
}); });
it('should initialize with the draft from the store', () => { it("should initialize with the draft from the store", () => {
const initialDraft = 'Existing draft message'; const initialDraft = "Existing draft message";
mockGetDraft.mockImplementation((key) => { mockGetDraft.mockImplementation((key) => {
return key === defaultProps.to ? initialDraft : ''; return key === defaultProps.to ? initialDraft : "";
}); });
renderComponent(); renderComponent();
const inputElement = screen.getByPlaceholderText('Enter Message') as HTMLInputElement; const inputElement = screen.getByPlaceholderText(
"Enter Message",
) as HTMLInputElement;
expect(inputElement.value).toBe(initialDraft); expect(inputElement.value).toBe(initialDraft);
expect(mockGetDraft).toHaveBeenCalledWith(defaultProps.to); expect(mockGetDraft).toHaveBeenCalledWith(defaultProps.to);
const expectedBytes = new Blob([initialDraft]).size; const expectedBytes = new Blob([initialDraft]).size;
expect(screen.getByTestId('byte-counter')).toHaveTextContent(`${expectedBytes}/${defaultProps.maxBytes}`); expect(screen.getByTestId("byte-counter")).toHaveTextContent(
`${expectedBytes}/${defaultProps.maxBytes}`,
);
}); });
it('should update input value, byte counter, and call setDraft on change within limits', () => { it("should update input value, byte counter, and call setDraft on change within limits", () => {
renderComponent(); renderComponent();
const inputElement = screen.getByPlaceholderText('Enter Message'); const inputElement = screen.getByPlaceholderText("Enter Message");
const testMessage = 'Hello there!'; const testMessage = "Hello there!";
const expectedBytes = new Blob([testMessage]).size; const expectedBytes = new Blob([testMessage]).size;
fireEvent.change(inputElement, { target: { value: testMessage } }); fireEvent.change(inputElement, { target: { value: testMessage } });
expect((inputElement as HTMLInputElement).value).toBe(testMessage); expect((inputElement as HTMLInputElement).value).toBe(testMessage);
expect(screen.getByTestId('byte-counter')).toHaveTextContent(`${expectedBytes}/${defaultProps.maxBytes}`); expect(screen.getByTestId("byte-counter")).toHaveTextContent(
`${expectedBytes}/${defaultProps.maxBytes}`,
);
expect(mockSetDraft).toHaveBeenCalledTimes(1); expect(mockSetDraft).toHaveBeenCalledTimes(1);
expect(mockSetDraft).toHaveBeenCalledWith(defaultProps.to, testMessage); expect(mockSetDraft).toHaveBeenCalledWith(defaultProps.to, testMessage);
}); });
it('should NOT update input value or call setDraft if maxBytes is exceeded', () => { it("should NOT update input value or call setDraft if maxBytes is exceeded", () => {
const smallMaxBytes = 5; const smallMaxBytes = 5;
renderComponent({ maxBytes: smallMaxBytes }); renderComponent({ maxBytes: smallMaxBytes });
const inputElement = screen.getByPlaceholderText('Enter Message'); const inputElement = screen.getByPlaceholderText("Enter Message");
const initialValue = '12345'; const initialValue = "12345";
const excessiveValue = '123456'; const excessiveValue = "123456";
fireEvent.change(inputElement, { target: { value: initialValue } }); fireEvent.change(inputElement, { target: { value: initialValue } });
expect((inputElement as HTMLInputElement).value).toBe(initialValue); expect((inputElement as HTMLInputElement).value).toBe(initialValue);
@ -120,15 +142,17 @@ describe('MessageInput', () => {
fireEvent.change(inputElement, { target: { value: excessiveValue } }); fireEvent.change(inputElement, { target: { value: excessiveValue } });
expect((inputElement as HTMLInputElement).value).toBe(initialValue); expect((inputElement as HTMLInputElement).value).toBe(initialValue);
expect(screen.getByTestId('byte-counter')).toHaveTextContent(`${smallMaxBytes}/${smallMaxBytes}`); expect(screen.getByTestId("byte-counter")).toHaveTextContent(
`${smallMaxBytes}/${smallMaxBytes}`,
);
expect(mockSetDraft).not.toHaveBeenCalled(); expect(mockSetDraft).not.toHaveBeenCalled();
}); });
it('should call onSend, clear input, reset byte counter, and call clearDraft on valid submit', async () => { it("should call onSend, clear input, reset byte counter, and call clearDraft on valid submit", async () => {
renderComponent(); renderComponent();
const inputElement = screen.getByPlaceholderText('Enter Message'); const inputElement = screen.getByPlaceholderText("Enter Message");
const formElement = screen.getByRole('form'); const formElement = screen.getByRole("form");
const testMessage = 'Send this message'; const testMessage = "Send this message";
fireEvent.change(inputElement, { target: { value: testMessage } }); fireEvent.change(inputElement, { target: { value: testMessage } });
fireEvent.submit(formElement); fireEvent.submit(formElement);
@ -136,21 +160,25 @@ describe('MessageInput', () => {
await waitFor(() => { await waitFor(() => {
expect(mockOnSend).toHaveBeenCalledTimes(1); expect(mockOnSend).toHaveBeenCalledTimes(1);
expect(mockOnSend).toHaveBeenCalledWith(testMessage); expect(mockOnSend).toHaveBeenCalledWith(testMessage);
expect((inputElement as HTMLInputElement).value).toBe(''); expect((inputElement as HTMLInputElement).value).toBe("");
expect(screen.getByTestId('byte-counter')).toHaveTextContent(`0/${defaultProps.maxBytes}`); expect(screen.getByTestId("byte-counter")).toHaveTextContent(
`0/${defaultProps.maxBytes}`,
);
expect(mockClearDraft).toHaveBeenCalledTimes(1); expect(mockClearDraft).toHaveBeenCalledTimes(1);
expect(mockClearDraft).toHaveBeenCalledWith(defaultProps.to); expect(mockClearDraft).toHaveBeenCalledWith(defaultProps.to);
}); });
}); });
it('should trim whitespace before calling onSend', async () => { it("should trim whitespace before calling onSend", async () => {
renderComponent(); renderComponent();
const inputElement = screen.getByPlaceholderText('Enter Message'); const inputElement = screen.getByPlaceholderText("Enter Message");
const formElement = screen.getByRole('form'); const formElement = screen.getByRole("form");
const testMessageWithWhitespace = ' Trim me! '; const testMessageWithWhitespace = " Trim me! ";
const expectedTrimmedMessage = 'Trim me!'; const expectedTrimmedMessage = "Trim me!";
fireEvent.change(inputElement, { target: { value: testMessageWithWhitespace } }); fireEvent.change(inputElement, {
target: { value: testMessageWithWhitespace },
});
fireEvent.submit(formElement); fireEvent.submit(formElement);
await waitFor(() => { await waitFor(() => {
@ -160,28 +188,28 @@ describe('MessageInput', () => {
}); });
}); });
it('should not call onSend or clearDraft if input is empty on submit', async () => { it("should not call onSend or clearDraft if input is empty on submit", async () => {
renderComponent(); renderComponent();
const inputElement = screen.getByPlaceholderText('Enter Message'); const inputElement = screen.getByPlaceholderText("Enter Message");
const formElement = screen.getByRole('form'); const formElement = screen.getByRole("form");
expect((inputElement as HTMLInputElement).value).toBe(''); expect((inputElement as HTMLInputElement).value).toBe("");
fireEvent.submit(formElement); fireEvent.submit(formElement);
await act(async () => { await act(async () => {
await new Promise(resolve => setTimeout(resolve, 50)); await new Promise((resolve) => setTimeout(resolve, 50));
}); });
expect(mockOnSend).not.toHaveBeenCalled(); expect(mockOnSend).not.toHaveBeenCalled();
expect(mockClearDraft).not.toHaveBeenCalled(); expect(mockClearDraft).not.toHaveBeenCalled();
}); });
it('should not call onSend or clearDraft if input contains only whitespace on submit', async () => { it("should not call onSend or clearDraft if input contains only whitespace on submit", async () => {
renderComponent(); renderComponent();
const inputElement = screen.getByTestId('message-input-field'); const inputElement = screen.getByTestId("message-input-field");
const formElement = screen.getByRole('form'); const formElement = screen.getByRole("form");
const whitespaceMessage = ' \t '; const whitespaceMessage = " \t ";
fireEvent.change(inputElement, { target: { value: whitespaceMessage } }); fireEvent.change(inputElement, { target: { value: whitespaceMessage } });
expect((inputElement as HTMLInputElement).value).toBe(whitespaceMessage); expect((inputElement as HTMLInputElement).value).toBe(whitespaceMessage);
@ -189,7 +217,7 @@ describe('MessageInput', () => {
fireEvent.submit(formElement); fireEvent.submit(formElement);
await act(async () => { await act(async () => {
await new Promise(resolve => setTimeout(resolve, 50)); await new Promise((resolve) => setTimeout(resolve, 50));
}); });
expect(mockOnSend).not.toHaveBeenCalled(); expect(mockOnSend).not.toHaveBeenCalled();
@ -198,18 +226,22 @@ describe('MessageInput', () => {
expect((inputElement as HTMLInputElement).value).toBe(whitespaceMessage); expect((inputElement as HTMLInputElement).value).toBe(whitespaceMessage);
}); });
it('should work with broadcast destination for drafts', () => { it("should work with broadcast destination for drafts", () => {
const broadcastDest: Types.Destination = 'broadcast'; const broadcastDest: Types.Destination = "broadcast";
mockGetDraft.mockImplementation((key) => key === broadcastDest ? 'Broadcast draft' : ''); mockGetDraft.mockImplementation((key) =>
key === broadcastDest ? "Broadcast draft" : ""
);
renderComponent({ to: broadcastDest }); renderComponent({ to: broadcastDest });
expect(mockGetDraft).toHaveBeenCalledWith(broadcastDest); expect(mockGetDraft).toHaveBeenCalledWith(broadcastDest);
expect((screen.getByPlaceholderText('Enter Message') as HTMLInputElement).value).toBe('Broadcast draft'); expect(
(screen.getByPlaceholderText("Enter Message") as HTMLInputElement).value,
).toBe("Broadcast draft");
const inputElement = screen.getByPlaceholderText('Enter Message'); const inputElement = screen.getByPlaceholderText("Enter Message");
const formElement = screen.getByRole('form'); const formElement = screen.getByRole("form");
const newMessage = 'New broadcast msg'; const newMessage = "New broadcast msg";
fireEvent.change(inputElement, { target: { value: newMessage } }); fireEvent.change(inputElement, { target: { value: newMessage } });
expect(mockSetDraft).toHaveBeenCalledWith(broadcastDest, newMessage); expect(mockSetDraft).toHaveBeenCalledWith(broadcastDest, newMessage);
@ -219,4 +251,4 @@ describe('MessageInput', () => {
expect(mockOnSend).toHaveBeenCalledWith(newMessage); expect(mockOnSend).toHaveBeenCalledWith(newMessage);
expect(mockClearDraft).toHaveBeenCalledWith(broadcastDest); expect(mockClearDraft).toHaveBeenCalledWith(broadcastDest);
}); });
}); });

11
src/components/PageComponents/Messages/MessageInput.tsx

@ -22,7 +22,9 @@ export const MessageInput = ({
const initialDraft = getDraft(to); const initialDraft = getDraft(to);
const [localDraft, setLocalDraft] = useState(initialDraft); const [localDraft, setLocalDraft] = useState(initialDraft);
const [messageBytes, setMessageBytes] = useState(() => calculateBytes(initialDraft)); const [messageBytes, setMessageBytes] = useState(() =>
calculateBytes(initialDraft)
);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value; const newValue = e.target.value;
@ -63,7 +65,10 @@ export const MessageInput = ({
/> />
</label> </label>
<label data-testid="byte-counter" className="flex items-center w-20 p-1 text-sm place-content-end"> <label
data-testid="byte-counter"
className="flex items-center w-20 p-1 text-sm place-content-end"
>
{messageBytes}/{maxBytes} {messageBytes}/{maxBytes}
</label> </label>
@ -77,4 +82,4 @@ export const MessageInput = ({
</form> </form>
</div> </div>
); );
}; };

99
src/components/PageComponents/Messages/MessageItem.tsx

@ -11,7 +11,10 @@ import { Avatar } from "@components/UI/Avatar.tsx";
import { AlertCircle, CheckCircle2, CircleEllipsis } from "lucide-react"; import { AlertCircle, CheckCircle2, CircleEllipsis } from "lucide-react";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { ReactNode, useMemo } from "react"; import { ReactNode, useMemo } from "react";
import { MessageState, useMessageStore } from "@core/stores/messageStore/index.ts"; import {
MessageState,
useMessageStore,
} from "@core/stores/messageStore/index.ts";
import { Protobuf, Types } from "@meshtastic/js"; import { Protobuf, Types } from "@meshtastic/js";
import { Message } from "@core/stores/messageStore/types.ts"; import { Message } from "@core/stores/messageStore/types.ts";
// import { MessageActionsMenu } from "@components/PageComponents/Messages/MessageActionsMenu.tsx"; // Uncomment if needed later // import { MessageActionsMenu } from "@components/PageComponents/Messages/MessageActionsMenu.tsx"; // Uncomment if needed later
@ -24,17 +27,42 @@ interface MessageStatusInfo {
} }
const MESSAGE_STATUS_MAP: Record<MessageState, MessageStatusInfo> = { const MESSAGE_STATUS_MAP: Record<MessageState, MessageStatusInfo> = {
[MessageState.Ack]: { displayText: "Message delivered", icon: CheckCircle2, ariaLabel: "Message delivered", iconClassName: "text-green-500" }, [MessageState.Ack]: {
[MessageState.Waiting]: { displayText: "Waiting for delivery", icon: CircleEllipsis, ariaLabel: "Sending message", iconClassName: "text-slate-400" }, displayText: "Message delivered",
[MessageState.Failed]: { displayText: "Delivery failed", icon: AlertCircle, ariaLabel: "Message delivery failed", iconClassName: "text-red-500 dark:text-red-400" }, 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 UNKNOWN_STATUS: MessageStatusInfo = { displayText: "Unknown state", icon: AlertCircle, ariaLabel: "Message status unknown", iconClassName: "text-red-500 dark:text-red-400" }; const UNKNOWN_STATUS: MessageStatusInfo = {
displayText: "Unknown state",
icon: AlertCircle,
ariaLabel: "Message status unknown",
iconClassName: "text-red-500 dark:text-red-400",
};
const getMessageStatusInfo = (state: MessageState): MessageStatusInfo => const getMessageStatusInfo = (state: MessageState): MessageStatusInfo =>
MESSAGE_STATUS_MAP[state] ?? UNKNOWN_STATUS; MESSAGE_STATUS_MAP[state] ?? UNKNOWN_STATUS;
const StatusTooltip = ({ statusInfo, children }: { statusInfo: MessageStatusInfo; children: ReactNode }) => ( const StatusTooltip = (
{ statusInfo, children }: {
statusInfo: MessageStatusInfo;
children: ReactNode;
},
) => (
<TooltipProvider delayDuration={300}> <TooltipProvider delayDuration={300}>
<Tooltip> <Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger> <TooltipTrigger asChild>{children}</TooltipTrigger>
@ -52,18 +80,17 @@ interface MessageItemProps {
export const MessageItem = ({ message }: MessageItemProps) => { export const MessageItem = ({ message }: MessageItemProps) => {
const { getNode } = useDevice(); const { getNode } = useDevice();
const { getMyNodeNum } = useMessageStore() const { getMyNodeNum } = useMessageStore();
const messageUser: Protobuf.Mesh.NodeInfo | null | undefined = useMemo(() => { const messageUser: Protobuf.Mesh.NodeInfo | null | undefined = useMemo(() => {
return message.from != null ? getNode(message.from) : null; return message.from != null ? getNode(message.from) : null;
}, [getNode, message.from]); }, [getNode, message.from]);
const myNodeNum = useMemo(() => getMyNodeNum(), [getMyNodeNum]); const myNodeNum = useMemo(() => getMyNodeNum(), [getMyNodeNum]);
const { displayName, shortName } = useMemo(() => { const { displayName, shortName } = useMemo(() => {
const userIdHex = message.from.toString(16).toUpperCase().padStart(2, '0'); const userIdHex = message.from.toString(16).toUpperCase().padStart(2, "0");
const last4 = userIdHex.slice(-4); const last4 = userIdHex.slice(-4);
const fallbackName = `Meshtastic ${last4}` const fallbackName = `Meshtastic ${last4}`;
const longName = messageUser?.user?.longName; const longName = messageUser?.user?.longName;
const derivedShortName = messageUser?.user?.shortName || fallbackName; const derivedShortName = messageUser?.user?.shortName || fallbackName;
const derivedDisplayName = longName || derivedShortName; const derivedDisplayName = longName || derivedShortName;
@ -73,22 +100,35 @@ export const MessageItem = ({ message }: MessageItemProps) => {
const messageStatusInfo = getMessageStatusInfo(message.state); const messageStatusInfo = getMessageStatusInfo(message.state);
const StatusIconComponent = messageStatusInfo.icon; const StatusIconComponent = messageStatusInfo.icon;
const messageDate = useMemo(() => message.date ? new Date(message.date) : null, [message.date]); const messageDate = useMemo(
const locale = 'en-US'; // TODO: Make dynamic via props or context () => message.date ? new Date(message.date) : null,
[message.date],
const formattedTime = useMemo(() => );
messageDate?.toLocaleTimeString(locale, { hour: 'numeric', minute: '2-digit', hour12: true }) ?? '', const locale = "en-US"; // TODO: Make dynamic via props or context
[messageDate, locale]);
const formattedTime = useMemo(
() =>
messageDate?.toLocaleTimeString(locale, {
hour: "numeric",
minute: "2-digit",
hour12: true,
}) ?? "",
[messageDate, locale],
);
const fullDateTime = useMemo(() => const fullDateTime = useMemo(
messageDate?.toLocaleString(locale, { dateStyle: 'medium', timeStyle: 'short' }) ?? '', () =>
[messageDate, locale]); messageDate?.toLocaleString(locale, {
dateStyle: "medium",
timeStyle: "short",
}) ?? "",
[messageDate, locale],
);
const isSender = myNodeNum !== undefined && message.from === myNodeNum; const isSender = myNodeNum !== undefined && message.from === myNodeNum;
const isOnPrimaryChannel = message.channel === Types.ChannelNumber.Primary; // Use the enum const isOnPrimaryChannel = message.channel === Types.ChannelNumber.Primary; // Use the enum
const shouldShowStatusIcon = isSender && isOnPrimaryChannel; const shouldShowStatusIcon = isSender && isOnPrimaryChannel;
const messageItemWrapperClass = cn( const messageItemWrapperClass = cn(
"group w-full py-2 relative list-none", "group w-full py-2 relative list-none",
"rounded-md", "rounded-md",
@ -97,7 +137,6 @@ export const MessageItem = ({ message }: MessageItemProps) => {
); );
const dateTextStyle = "text-xs text-slate-500 dark:text-slate-400"; const dateTextStyle = "text-xs text-slate-500 dark:text-slate-400";
return ( return (
<li className={messageItemWrapperClass}> <li className={messageItemWrapperClass}>
<div className="grid grid-cols-[auto_1fr] gap-x-2"> <div className="grid grid-cols-[auto_1fr] gap-x-2">
@ -109,7 +148,10 @@ export const MessageItem = ({ message }: MessageItemProps) => {
{displayName} {displayName}
</span> </span>
{messageDate && ( {messageDate && (
<time dateTime={messageDate.toISOString()} className={dateTextStyle}> <time
dateTime={messageDate.toISOString()}
className={dateTextStyle}
>
<span aria-hidden="true">{formattedTime}</span> <span aria-hidden="true">{formattedTime}</span>
<span className="sr-only">{fullDateTime}</span> <span className="sr-only">{fullDateTime}</span>
</time> </time>
@ -118,7 +160,10 @@ export const MessageItem = ({ message }: MessageItemProps) => {
<StatusTooltip statusInfo={messageStatusInfo}> <StatusTooltip statusInfo={messageStatusInfo}>
<span aria-label={messageStatusInfo.ariaLabel} role="img"> <span aria-label={messageStatusInfo.ariaLabel} role="img">
<StatusIconComponent <StatusIconComponent
className={cn("size-4 shrink-0", messageStatusInfo.iconClassName)} className={cn(
"size-4 shrink-0",
messageStatusInfo.iconClassName,
)}
aria-hidden="true" aria-hidden="true"
/> />
</span> </span>
@ -134,9 +179,11 @@ export const MessageItem = ({ message }: MessageItemProps) => {
</div> </div>
</div> </div>
{/* Actions Menu Placeholder */} {/* Actions Menu Placeholder */}
{/* <div className="absolute top-1 right-1"> {
/* <div className="absolute top-1 right-1">
<MessageActionsMenu onReply={() => console.log("Reply")} /> <MessageActionsMenu onReply={() => console.log("Reply")} />
</div> */} </div> */
}
</li> </li>
); );
}; };

25
src/components/PageComponents/Messages/TraceRoute.test.tsx

@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import { TraceRoute } from "@components/PageComponents/Messages/TraceRoute.tsx"; import { TraceRoute } from "@components/PageComponents/Messages/TraceRoute.tsx";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
@ -34,11 +34,11 @@ describe("TraceRoute", () => {
it("renders the route to destination with SNR values", () => { it("renders the route to destination with SNR values", () => {
render( render(
<TraceRoute <TraceRoute
from={{ user: { longName: "Source Node" } } as any} from={{ user: { longName: "Source Node" } }}
to={{ user: { longName: "Destination Node" } } as any} to={{ user: { longName: "Destination Node" } }}
route={[1, 2]} route={[1, 2]}
snrTowards={[10, 20, 30]} snrTowards={[10, 20, 30]}
/> />,
); );
expect(screen.getAllByText("Source Node")).toHaveLength(1); expect(screen.getAllByText("Source Node")).toHaveLength(1);
@ -56,13 +56,13 @@ describe("TraceRoute", () => {
it("renders the route back when provided", () => { it("renders the route back when provided", () => {
render( render(
<TraceRoute <TraceRoute
from={{ user: { longName: "Source Node" } } as any} from={{ user: { longName: "Source Node" } }}
to={{ user: { longName: "Destination Node" } } as any} to={{ user: { longName: "Destination Node" } }}
route={[1]} route={[1]}
snrTowards={[15, 25]} snrTowards={[15, 25]}
routeBack={[3]} routeBack={[3]}
snrBack={[35, 45]} snrBack={[35, 45]}
/> />,
); );
expect(screen.getByText("Route back:")).toBeInTheDocument(); expect(screen.getByText("Route back:")).toBeInTheDocument();
@ -79,16 +79,15 @@ describe("TraceRoute", () => {
expect(screen.getByText("↓ 15dB")).toBeInTheDocument(); expect(screen.getByText("↓ 15dB")).toBeInTheDocument();
expect(screen.getByText("↓ 25dB")).toBeInTheDocument(); expect(screen.getByText("↓ 25dB")).toBeInTheDocument();
}); });
it("renders '??' for missing SNR values", () => { it("renders '??' for missing SNR values", () => {
render( render(
<TraceRoute <TraceRoute
from={{ user: { longName: "Source" } } as any} from={{ user: { longName: "Source" } }}
to={{ user: { longName: "Dest" } } as any} to={{ user: { longName: "Dest" } }}
route={[1]} route={[1]}
/> />,
); );
expect(screen.getByText("Node A")).toBeInTheDocument(); expect(screen.getByText("Node A")).toBeInTheDocument();
@ -102,11 +101,11 @@ describe("TraceRoute", () => {
to={{ user: { longName: "Dest" } } as unknown} to={{ user: { longName: "Dest" } } as unknown}
route={[99]} route={[99]}
snrTowards={[5, 15]} snrTowards={[5, 15]}
/> />,
); );
expect(screen.getByText(/^!63$/)).toBeInTheDocument(); expect(screen.getByText(/^!63$/)).toBeInTheDocument();
expect(screen.getByText("↓ 5dB")).toBeInTheDocument(); expect(screen.getByText("↓ 5dB")).toBeInTheDocument();
expect(screen.getByText("↓ 15dB")).toBeInTheDocument(); expect(screen.getByText("↓ 15dB")).toBeInTheDocument();
}); });
}); });

13
src/components/PageComponents/Messages/TraceRoute.tsx

@ -19,17 +19,24 @@ interface RoutePathProps {
snr?: number[]; snr?: number[];
} }
const RoutePath = ({ title, startNode, endNode, path, snr }: RoutePathProps) => { const RoutePath = (
{ title, startNode, endNode, path, snr }: RoutePathProps,
) => {
const { getNode } = useDevice(); const { getNode } = useDevice();
return ( return (
<span id={title} className="ml-4 border-l-2 border-l-background-primary pl-2 text-slate-900 dark:text-slate-900"> <span
id={title}
className="ml-4 border-l-2 border-l-background-primary pl-2 text-slate-900 dark:text-slate-900"
>
<p className="font-semibold">{title}</p> <p className="font-semibold">{title}</p>
<p>{startNode?.user?.longName}</p> <p>{startNode?.user?.longName}</p>
<p> {snr?.[0] ?? "??"}dB</p> <p> {snr?.[0] ?? "??"}dB</p>
{path.map((hop, i) => ( {path.map((hop, i) => (
<span key={getNode(hop)?.num ?? hop}> <span key={getNode(hop)?.num ?? hop}>
<p>{getNode(hop)?.user?.longName ?? `!${numberToHexUnpadded(hop)}`}</p> <p>
{getNode(hop)?.user?.longName ?? `!${numberToHexUnpadded(hop)}`}
</p>
<p> {snr?.[i + 1] ?? "??"}dB</p> <p> {snr?.[i + 1] ?? "??"}dB</p>
</span> </span>
))} ))}

18
src/components/PageLayout.tsx

@ -1,4 +1,4 @@
import React from 'react'; import React from "react";
import { cn } from "@core/utils/cn.ts"; import { cn } from "@core/utils/cn.ts";
import { type LucideIcon } from "lucide-react"; import { type LucideIcon } from "lucide-react";
import Footer from "@components/UI/Footer.tsx"; import Footer from "@components/UI/Footer.tsx";
@ -39,7 +39,7 @@ export const PageLayout = ({
leftBarClassName, leftBarClassName,
rightBarClassName, rightBarClassName,
topBarClassName, topBarClassName,
contentClassName contentClassName,
}: PageLayoutProps) => { }: PageLayoutProps) => {
return ( return (
<ErrorBoundary FallbackComponent={ErrorPage}> <ErrorBoundary FallbackComponent={ErrorPage}>
@ -49,7 +49,7 @@ export const PageLayout = ({
<aside <aside
className={cn( className={cn(
"px-2 pr-0 shrink-0 border-r-[0.5px] border-slate-300 dark:border-slate-700 ", "px-2 pr-0 shrink-0 border-r-[0.5px] border-slate-300 dark:border-slate-700 ",
leftBarClassName leftBarClassName,
)} )}
> >
{leftBar} {leftBar}
@ -61,7 +61,7 @@ export const PageLayout = ({
<header <header
className={cn( className={cn(
"flex h-14 shrink-0 mt-2 p-2 items-center border-b border-slate-300 dark:border-slate-700", "flex h-14 shrink-0 mt-2 p-2 items-center border-b border-slate-300 dark:border-slate-700",
topBarClassName topBarClassName,
)} )}
> >
{/* Header Content */} {/* Header Content */}
@ -82,9 +82,7 @@ export const PageLayout = ({
aria-busy={action.isLoading} aria-busy={action.isLoading}
> >
<div className="mr-6"> <div className="mr-6">
{action.isLoading ? ( {action.isLoading ? <Spinner size="md" /> : (
<Spinner size="md" />
) : (
<action.icon <action.icon
className={cn("h-5 w-5", action.iconClasses)} className={cn("h-5 w-5", action.iconClasses)}
/> />
@ -101,7 +99,7 @@ export const PageLayout = ({
"flex-1 flex flex-col", "flex-1 flex flex-col",
"overflow-hidden", "overflow-hidden",
!noPadding && "px-2", !noPadding && "px-2",
contentClassName contentClassName,
)} )}
> >
{children} {children}
@ -114,7 +112,7 @@ export const PageLayout = ({
<aside <aside
className={cn( className={cn(
"w-48 lg:w-[270px] shrink-0 border-l border-slate-300 dark:border-slate-700 px-2 overflow-hidden", "w-48 lg:w-[270px] shrink-0 border-l border-slate-300 dark:border-slate-700 px-2 overflow-hidden",
rightBarClassName rightBarClassName,
)} )}
> >
{rightBar} {rightBar}
@ -123,4 +121,4 @@ export const PageLayout = ({
</div> </div>
</ErrorBoundary> </ErrorBoundary>
); );
}; };

2
src/components/ThemeSwitcher.tsx

@ -9,7 +9,7 @@ export default function ThemeSwitcher({
}: { }: {
className?: string; className?: string;
}) { }) {
const { theme, preference, setPreference } = useTheme(); const { preference, setPreference } = useTheme();
const themeIcons = { const themeIcons = {
light: <Sun className="size-6" />, light: <Sun className="size-6" />,

22
src/components/UI/Avatar.tsx

@ -1,5 +1,5 @@
import { cn } from "@core/utils/cn.ts"; import { cn } from "@core/utils/cn.ts";
import { LockKeyholeOpenIcon } from 'lucide-react'; import { LockKeyholeOpenIcon } from "lucide-react";
type RGBColor = { type RGBColor = {
r: number; r: number;
@ -88,17 +88,17 @@ export const Avatar = ({
color: textColor, color: textColor,
}} }}
> >
{showError ? ( {showError
<LockKeyholeOpenIcon ? (
className="absolute bottom-0 right-0 z-10 size-4 text-red-500 stroke-3" <LockKeyholeOpenIcon
aria-hidden="true" className="absolute bottom-0 right-0 z-10 size-4 text-red-500 stroke-3"
/> aria-hidden="true"
) : null} />
<p )
className="p-1" : null}
> <p className="p-1">
{initials} {initials}
</p> </p>
</div> </div>
); );
}; };

9
src/components/UI/Button.tsx

@ -39,8 +39,9 @@ const buttonVariants = cva(
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"]; export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
export interface ButtonProps export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, extends
VariantProps<typeof buttonVariants> { React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
icon?: React.ReactNode; icon?: React.ReactNode;
iconAlignment?: "left" | "right"; iconAlignment?: "left" | "right";
} }
@ -65,7 +66,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
className={cn( className={cn(
buttonVariants({ variant, size, className }), buttonVariants({ variant, size, className }),
{ "cursor-not-allowed": disabled }, { "cursor-not-allowed": disabled },
"inline-flex items-center" "inline-flex items-center",
)} )}
ref={ref} ref={ref}
disabled={disabled} disabled={disabled}
@ -85,4 +86,4 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
); );
Button.displayName = "Button"; Button.displayName = "Button";
export { Button, buttonVariants }; export { Button, buttonVariants };

111
src/components/UI/Checkbox/Checkbox.test.tsx

@ -1,113 +1,130 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, fireEvent, cleanup } from '@testing-library/react'; import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { Checkbox } from '@components/UI/Checkbox/index.tsx'; import { Checkbox } from "@components/UI/Checkbox/index.tsx";
import React from "react"; import React from "react";
vi.mock('@components/UI/Label.tsx', () => ({ vi.mock("@components/UI/Label.tsx", () => ({
Label: ({ children, className, htmlFor, id }: { children: React.ReactNode; className: string; htmlFor: string; id: string }) => ( Label: (
<label data-testid="label-component" className={className} htmlFor={htmlFor} id={id}> { children, className, htmlFor, id }: {
children: React.ReactNode;
className: string;
htmlFor: string;
id: string;
},
) => (
<label
data-testid="label-component"
className={className}
htmlFor={htmlFor}
id={id}
>
{children} {children}
</label> </label>
), ),
})); }));
vi.mock('@core/utils/cn.ts', () => ({ vi.mock("@core/utils/cn.ts", () => ({
cn: (...args: any) => args.filter(Boolean).join(' '), cn: (...args) => args.filter(Boolean).join(" "),
})); }));
vi.mock('react', async () => { vi.mock("react", async () => {
const actual = await vi.importActual('react'); const actual = await vi.importActual("react");
return { return {
...actual, ...actual,
useId: () => 'test-id', useId: () => "test-id",
}; };
}); });
describe('Checkbox', () => { describe("Checkbox", () => {
beforeEach(cleanup); beforeEach(cleanup);
it('renders unchecked by default', () => { it("renders unchecked by default", () => {
render(<Checkbox />); render(<Checkbox />);
const checkbox = screen.getByRole('checkbox'); const checkbox = screen.getByRole("checkbox");
expect(checkbox).not.toBeChecked(); expect(checkbox).not.toBeChecked();
expect(screen.queryByText('Check')).not.toBeInTheDocument(); expect(screen.queryByText("Check")).not.toBeInTheDocument();
}); });
it('renders checked when checked prop is true', () => { it("renders checked when checked prop is true", () => {
render(<Checkbox checked={true} />); render(<Checkbox checked />);
expect(screen.getByRole('checkbox')).toBeChecked(); expect(screen.getByRole("checkbox")).toBeChecked();
expect(screen.getByRole('presentation')).toBeInTheDocument(); expect(screen.getByRole("presentation")).toBeInTheDocument();
}); });
it('calls onChange when clicked', () => { it("calls onChange when clicked", () => {
const onChange = vi.fn(); const onChange = vi.fn();
render(<Checkbox onChange={onChange} />); render(<Checkbox onChange={onChange} />);
fireEvent.click(screen.getByRole('presentation')); fireEvent.click(screen.getByRole("presentation"));
expect(onChange).toHaveBeenCalledWith(true); expect(onChange).toHaveBeenCalledWith(true);
fireEvent.click(screen.getByRole('presentation')); fireEvent.click(screen.getByRole("presentation"));
expect(onChange).toHaveBeenCalledWith(false); expect(onChange).toHaveBeenCalledWith(false);
}); });
it('uses provided id', () => { it("uses provided id", () => {
render(<Checkbox id="custom-id" />); render(<Checkbox id="custom-id" />);
expect(screen.getByRole('checkbox').id).toBe('custom-id'); expect(screen.getByRole("checkbox").id).toBe("custom-id");
}); });
it('generates id when not provided', () => { it("generates id when not provided", () => {
render(<Checkbox />); render(<Checkbox />);
expect(screen.getByRole('checkbox').id).toBe('test-id'); expect(screen.getByRole("checkbox").id).toBe("test-id");
}); });
it('renders children in Label component', () => { it("renders children in Label component", () => {
render(<Checkbox>Test Label</Checkbox>); render(<Checkbox>Test Label</Checkbox>);
expect(screen.getByTestId('label-component')).toHaveTextContent('Test Label'); expect(screen.getByTestId("label-component")).toHaveTextContent(
"Test Label",
);
}); });
it('applies custom className', () => { it("applies custom className", () => {
const { container } = render(<Checkbox className="custom-class" />); const { container } = render(<Checkbox className="custom-class" />);
expect(container.firstChild).toHaveClass('custom-class'); expect(container.firstChild).toHaveClass("custom-class");
}); });
it('applies labelClassName to Label', () => { it("applies labelClassName to Label", () => {
render(<Checkbox labelClassName="label-class">Test</Checkbox>); render(<Checkbox labelClassName="label-class">Test</Checkbox>);
expect(screen.getByTestId('label-component')).toHaveClass('label-class'); expect(screen.getByTestId("label-component")).toHaveClass("label-class");
}); });
it('disables checkbox when disabled prop is true', () => { it("disables checkbox when disabled prop is true", () => {
render(<Checkbox disabled />); render(<Checkbox disabled />);
expect(screen.getByRole('checkbox')).toBeDisabled(); expect(screen.getByRole("checkbox")).toBeDisabled();
expect(screen.getByRole('presentation')).toHaveClass('opacity-50'); expect(screen.getByRole("presentation")).toHaveClass("opacity-50");
}); });
it('does not call onChange when disabled', () => { it("does not call onChange when disabled", () => {
const onChange = vi.fn(); const onChange = vi.fn();
render(<Checkbox onChange={onChange} disabled />); render(<Checkbox onChange={onChange} disabled />);
fireEvent.click(screen.getByRole('presentation')); fireEvent.click(screen.getByRole("presentation"));
expect(onChange).not.toHaveBeenCalled(); expect(onChange).not.toHaveBeenCalled();
}); });
it('sets required attribute when required prop is true', () => { it("sets required attribute when required prop is true", () => {
render(<Checkbox required />); render(<Checkbox required />);
expect(screen.getByRole('checkbox')).toHaveAttribute('required'); expect(screen.getByRole("checkbox")).toHaveAttribute("required");
}); });
it('sets name attribute when name prop is provided', () => { it("sets name attribute when name prop is provided", () => {
render(<Checkbox name="test-name" />); render(<Checkbox name="test-name" />);
expect(screen.getByRole('checkbox')).toHaveAttribute('name', 'test-name'); expect(screen.getByRole("checkbox")).toHaveAttribute("name", "test-name");
}); });
it('passes through additional props', () => { it("passes through additional props", () => {
render(<Checkbox data-testid="extra-prop" />); render(<Checkbox data-testid="extra-prop" />);
expect(screen.getByRole('checkbox')).toHaveAttribute('data-testid', 'extra-prop'); expect(screen.getByRole("checkbox")).toHaveAttribute(
"data-testid",
"extra-prop",
);
}); });
it('toggles checked state correctly', () => { it("toggles checked state correctly", () => {
render(<Checkbox />); render(<Checkbox />);
const checkbox = screen.getByRole('checkbox'); const checkbox = screen.getByRole("checkbox");
const presentation = screen.getByRole('presentation'); const presentation = screen.getByRole("presentation");
expect(checkbox).not.toBeChecked(); expect(checkbox).not.toBeChecked();
@ -117,4 +134,4 @@ describe('Checkbox', () => {
fireEvent.click(presentation); fireEvent.click(presentation);
expect(checkbox).not.toBeChecked(); expect(checkbox).not.toBeChecked();
}); });
}); });

11
src/components/UI/Dialog.tsx

@ -58,7 +58,9 @@ DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogClose = ({ const DialogClose = ({
className, className,
...props ...props
}: DialogPrimitive.DialogCloseProps & React.RefAttributes<HTMLButtonElement> & { className?: string }) => ( }: DialogPrimitive.DialogCloseProps & React.RefAttributes<HTMLButtonElement> & {
className?: string;
}) => (
<DialogPrimitive.Close <DialogPrimitive.Close
aria-label="Close" aria-label="Close"
data-testid="dialog-close-button" data-testid="dialog-close-button"
@ -107,7 +109,10 @@ const DialogTitle = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<DialogPrimitive.Title <DialogPrimitive.Title
ref={ref} ref={ref}
className={cn("text-lg font-semibold text-slate-900 dark:text-slate-100", className)} className={cn(
"text-lg font-semibold text-slate-900 dark:text-slate-100",
className,
)}
{...props} {...props}
/> />
)); ));
@ -127,11 +132,11 @@ DialogDescription.displayName = DialogPrimitive.Description.displayName;
export { export {
Dialog, Dialog,
DialogClose,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
DialogClose,
}; };

1
src/components/UI/ErrorPage.tsx

@ -4,7 +4,6 @@ import { Heading } from "@components/UI/Typography/Heading.tsx";
import { Link } from "@components/UI/Typography/Link.tsx"; import { Link } from "@components/UI/Typography/Link.tsx";
import { P } from "@components/UI/Typography/P.tsx"; import { P } from "@components/UI/Typography/P.tsx";
export function ErrorPage({ error }: { error: Error }) { export function ErrorPage({ error }: { error: Error }) {
if (!error) { if (!error) {
return null; return null;

11
src/components/UI/Footer.tsx

@ -1,13 +1,16 @@
import { cn } from "@core/utils/cn.ts" import { cn } from "@core/utils/cn.ts";
type FooterProps = { type FooterProps = {
className?: string; className?: string;
} };
const Footer = ({ className, ...props }: FooterProps) => { const Footer = ({ className, ...props }: FooterProps) => {
return ( return (
<footer <footer
className={cn("flex mt-auto justify-center py-2 px-4 text-sm lg:text-md", className)} className={cn(
"flex mt-auto justify-center py-2 px-4 text-sm lg:text-md",
className,
)}
{...props} {...props}
> >
<p> <p>
@ -27,6 +30,6 @@ const Footer = ({ className, ...props }: FooterProps) => {
</p> </p>
</footer> </footer>
); );
} };
export default Footer; export default Footer;

168
src/components/UI/Generator.tsx

@ -15,7 +15,8 @@ export interface ActionButton {
onClick: React.MouseEventHandler<HTMLButtonElement>; onClick: React.MouseEventHandler<HTMLButtonElement>;
variant: ButtonVariant; variant: ButtonVariant;
className?: string; className?: string;
}[] }
[];
export interface GeneratorProps extends React.BaseHTMLAttributes<HTMLElement> { export interface GeneratorProps extends React.BaseHTMLAttributes<HTMLElement> {
type: "text" | "password"; type: "text" | "password";
@ -26,97 +27,98 @@ export interface GeneratorProps extends React.BaseHTMLAttributes<HTMLElement> {
actionButtons: ActionButton[]; actionButtons: ActionButton[];
bits?: { text: string; value: string; key: string }[]; bits?: { text: string; value: string; key: string }[];
selectChange: (event: string) => void; selectChange: (event: string) => void;
inputChange: (event: React.ChangeEventHandler<HTMLInputElement> | undefined) => void; inputChange: (
event: React.ChangeEventHandler<HTMLInputElement> | undefined,
) => void;
showPasswordToggle?: boolean; showPasswordToggle?: boolean;
showCopyButton?: boolean; showCopyButton?: boolean;
disabled?: boolean; disabled?: boolean;
} }
const Generator = const Generator = (
( {
{ type,
type, devicePSKBitCount,
devicePSKBitCount, id = "pskInput",
id = "pskInput", variant,
variant, value,
value, actionButtons,
actionButtons, bits = [
bits = [ { text: "256 bit", value: "32", key: "bit256" },
{ text: "256 bit", value: "32", key: "bit256" }, { text: "128 bit", value: "16", key: "bit128" },
{ text: "128 bit", value: "16", key: "bit128" }, { text: "8 bit", value: "1", key: "bit8" },
{ text: "8 bit", value: "1", key: "bit8" }, { text: "Empty", value: "0", key: "empty" },
{ text: "Empty", value: "0", key: "empty" }, ],
], selectChange,
selectChange, inputChange,
inputChange, disabled,
disabled, showPasswordToggle,
showPasswordToggle, showCopyButton,
showCopyButton, ...props
...props }: GeneratorProps,
}: GeneratorProps ) => {
) => { const inputRef = React.useRef<HTMLInputElement>(null);
const inputRef = React.useRef<HTMLInputElement>(null);
// Invokes onChange event on the input element when the value changes from the parent component // Invokes onChange event on the input element when the value changes from the parent component
React.useEffect(() => { React.useEffect(() => {
if (!inputRef.current) return; if (!inputRef.current) return;
const setValue = Object.getOwnPropertyDescriptor( const setValue = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype, HTMLInputElement.prototype,
"value", "value",
)?.set; )?.set;
if (!setValue) return; if (!setValue) return;
inputRef.current.value = ""; inputRef.current.value = "";
setValue.call(inputRef.current, value); setValue.call(inputRef.current, value);
inputRef.current.dispatchEvent(new Event("input", { bubbles: true })); inputRef.current.dispatchEvent(new Event("input", { bubbles: true }));
}, [value]); }, [value]);
return ( return (
<> <>
<Input <Input
type={type} type={type}
id={id} id={id}
variant={variant} variant={variant}
value={value} value={value}
onChange={inputChange} onChange={inputChange}
disabled={disabled} disabled={disabled}
ref={inputRef} ref={inputRef}
showCopyButton={showCopyButton} showCopyButton={showCopyButton}
showPasswordToggle={showPasswordToggle} showPasswordToggle={showPasswordToggle}
/> />
<Select <Select
value={devicePSKBitCount?.toString()} value={devicePSKBitCount?.toString()}
onValueChange={(e) => selectChange(e)} onValueChange={(e) => selectChange(e)}
disabled={disabled} disabled={disabled}
> >
<SelectTrigger className="w-36 ml-2"> <SelectTrigger className="w-36 ml-2">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent className="w-36"> <SelectContent className="w-36">
{bits.map(({ text, value, key }) => ( {bits.map(({ text, value, key }) => (
<SelectItem key={key} value={value} className="w-36"> <SelectItem key={key} value={value} className="w-36">
{text}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex ml-2 space-x-2">
{actionButtons?.map(({ text, onClick, variant, className }) => (
<Button
key={text}
type="button"
onClick={onClick}
disabled={disabled}
variant={variant}
className={className}
{...props}
>
{text} {text}
</Button> </SelectItem>
))} ))}
</div> </SelectContent>
</> </Select>
); <div className="flex ml-2 space-x-2">
} {actionButtons?.map(({ text, onClick, variant, className }) => (
<Button
key={text}
type="button"
onClick={onClick}
disabled={disabled}
variant={variant}
className={className}
{...props}
>
{text}
</Button>
))}
</div>
</>
);
};
Generator.displayName = "Button"; Generator.displayName = "Button";
export { Generator }; export { Generator };

55
src/components/UI/Input.tsx

@ -1,7 +1,7 @@
import * as React from "react"; import * as React from "react";
import { cn } from "@core/utils/cn.ts"; import { cn } from "@core/utils/cn.ts";
import { cva, type VariantProps } from "class-variance-authority"; import { cva, type VariantProps } from "class-variance-authority";
import { Check, Copy, Eye, EyeOff, X, type LucideIcon } from "lucide-react"; import { Check, Copy, Eye, EyeOff, type LucideIcon, X } from "lucide-react";
import { useCopyToClipboard } from "@core/hooks/useCopyToClipboard.ts"; import { useCopyToClipboard } from "@core/hooks/useCopyToClipboard.ts";
import { usePasswordVisibilityToggle } from "@core/hooks/usePasswordVisibilityToggle.ts"; import { usePasswordVisibilityToggle } from "@core/hooks/usePasswordVisibilityToggle.ts";
@ -18,7 +18,7 @@ const inputVariants = cva(
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",
}, },
} },
); );
type InputActionType = { type InputActionType = {
@ -26,13 +26,14 @@ type InputActionType = {
icon: LucideIcon; icon: LucideIcon;
onClick: (e: React.MouseEvent<HTMLButtonElement>) => void; onClick: (e: React.MouseEvent<HTMLButtonElement>) => void;
ariaLabel: string; ariaLabel: string;
tooltip?: string tooltip?: string;
condition?: boolean; condition?: boolean;
}; };
export interface InputProps export interface InputProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "prefix" | "suffix">, extends
VariantProps<typeof inputVariants> { Omit<React.InputHTMLAttributes<HTMLInputElement>, "prefix" | "suffix">,
VariantProps<typeof inputVariants> {
prefix?: React.ReactNode; prefix?: React.ReactNode;
suffix?: React.ReactNode; suffix?: React.ReactNode;
showPasswordToggle?: boolean; showPasswordToggle?: boolean;
@ -57,7 +58,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
onChange, onChange,
...props ...props
}, },
ref ref,
) => { ) => {
const { isVisible, toggleVisibility } = usePasswordVisibilityToggle(); const { isVisible, toggleVisibility } = usePasswordVisibilityToggle();
const { copy, isCopied } = useCopyToClipboard({ timeout: 1500 }); const { copy, isCopied } = useCopyToClipboard({ timeout: 1500 });
@ -109,10 +110,11 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
}, },
]; ];
const actions = potentialActions.filter(action => action.condition); const actions = potentialActions.filter((action) => action.condition);
const inputType = const inputType = showPasswordToggle
showPasswordToggle ? (isVisible ? "text" : "password") : type; ? (isVisible ? "text" : "password")
: type;
const hasPrefix = !!prefix; const hasPrefix = !!prefix;
const hasSuffix = !!suffix; const hasSuffix = !!suffix;
@ -122,11 +124,13 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
inputVariants({ variant }), inputVariants({ variant }),
hasActions && !hasSuffix && "pr-10", hasActions && !hasSuffix && "pr-10",
hasPrefix && "rounded-l-none", hasPrefix && "rounded-l-none",
className className,
); );
return ( return (
<div className={cn("relative flex w-full items-stretch", containerClassName)}> <div
className={cn("relative flex w-full items-stretch", containerClassName)}
>
{prefix && ( {prefix && (
<span className="inline-flex items-center rounded-l-md border border-r-0 border-slate-300 bg-slate-100/80 px-3 text-sm text-slate-600 dark:border-slate-700 dark:bg-slate-200 dark:text-slate-700"> <span className="inline-flex items-center rounded-l-md border border-r-0 border-slate-300 bg-slate-100/80 px-3 text-sm text-slate-600 dark:border-slate-700 dark:bg-slate-200 dark:text-slate-700">
{prefix} {prefix}
@ -144,27 +148,32 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<div className="absolute right-0 top-0 flex h-full items-stretch"> <div className="absolute right-0 top-0 flex h-full items-stretch">
{suffix && ( {suffix && (
<span className={cn( <span
"inline-flex items-center border border-l-0 border-slate-300 bg-slate-100/80 px-3 text-sm text-slate-600 dark:border-slate-700 dark:bg-slate-700 dark:text-slate-300", className={cn(
!hasActions && "rounded-r-md" "inline-flex items-center border border-l-0 border-slate-300 bg-slate-100/80 px-3 text-sm text-slate-600 dark:border-slate-700 dark:bg-slate-700 dark:text-slate-300",
)}> !hasActions && "rounded-r-md",
)}
>
{suffix} {suffix}
</span> </span>
)} )}
{hasActions && ( {hasActions && (
<div className={cn( <div
"flex items-center divide-x divide-slate-300 border border-l-0 border-slate-300 dark:divide-slate-700 dark:border-slate-700", className={cn(
!hasSuffix && "rounded-r-md", "flex items-center divide-x divide-slate-300 border border-l-0 border-slate-300 dark:divide-slate-700 dark:border-slate-700",
"bg-white dark:bg-slate-800" !hasSuffix && "rounded-r-md",
)}> "bg-white dark:bg-slate-800",
)}
>
{actions.map((action) => ( {actions.map((action) => (
<button <button
key={action.id} key={action.id}
type="button" type="button"
className={cn( className={cn(
"inline-flex h-full items-center justify-center px-2.5 text-slate-500 hover:bg-slate-100 hover:text-slate-700 focus:outline-none focus:ring-1 focus:ring-slate-400 focus:ring-offset-0 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-200 dark:focus:ring-slate-500 hover:rounded-md dark:hover:rounded-md", "inline-flex h-full items-center justify-center px-2.5 text-slate-500 hover:bg-slate-100 hover:text-slate-700 focus:outline-none focus:ring-1 focus:ring-slate-400 focus:ring-offset-0 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-200 dark:focus:ring-slate-500 hover:rounded-md dark:hover:rounded-md",
action.id === 'copy-value' && isCopied && "text-green-600 dark:text-green-500" action.id === "copy-value" && isCopied &&
"text-green-600 dark:text-green-500",
)} )}
onClick={action.onClick} onClick={action.onClick}
aria-label={action.ariaLabel} aria-label={action.ariaLabel}
@ -178,8 +187,8 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
</div> </div>
</div> </div>
); );
} },
); );
Input.displayName = "Input"; Input.displayName = "Input";
export { Input, inputVariants }; export { Input, inputVariants };

20
src/components/UI/Sidebar/SidebarButton.tsx

@ -36,8 +36,8 @@ export const SidebarButton = ({
className={cn( className={cn(
"flex w-full items-center text-wrap", "flex w-full items-center text-wrap",
isButtonCollapsed isButtonCollapsed
? 'justify-center gap-0 px-2 h-9' ? "justify-center gap-0 px-2 h-9"
: 'justify-start gap-2 min-h-9' : "justify-start gap-2 min-h-9",
)} )}
disabled={disabled} disabled={disabled}
> >
@ -52,13 +52,13 @@ export const SidebarButton = ({
<span <span
className={cn( className={cn(
'flex flex-wrap justify-start text-left text-wrap break-all', "flex flex-wrap justify-start text-left text-wrap break-all",
'min-w-0', "min-w-0",
'px-1', "px-1",
'transition-all duration-300 ease-in-out', "transition-all duration-300 ease-in-out",
isButtonCollapsed isButtonCollapsed
? 'opacity-0 max-w-0 invisible w-0 overflow-hidden' ? "opacity-0 max-w-0 invisible w-0 overflow-hidden"
: 'opacity-100 max-w-full visible flex-1 whitespace-normal' : "opacity-100 max-w-full visible flex-1 whitespace-normal",
)} )}
> >
{label} {label}
@ -70,7 +70,7 @@ export const SidebarButton = ({
"ml-auto flex-shrink-0 justify-end text-white text-xs rounded-full px-1.5 py-0.5 bg-red-600", "ml-auto flex-shrink-0 justify-end text-white text-xs rounded-full px-1.5 py-0.5 bg-red-600",
"flex-shrink-0", "flex-shrink-0",
"transition-opacity duration-300 ease-in-out", "transition-opacity duration-300 ease-in-out",
isButtonCollapsed ? 'opacity-0 invisible' : 'opacity-100 visible' isButtonCollapsed ? "opacity-0 invisible" : "opacity-100 visible",
)} )}
> >
{count} {count}
@ -78,4 +78,4 @@ export const SidebarButton = ({
)} )}
</Button> </Button>
); );
}; };

20
src/components/UI/Sidebar/sidebarButton.tsx

@ -36,8 +36,8 @@ export const SidebarButton = ({
className={cn( className={cn(
"flex w-full items-center text-wrap", "flex w-full items-center text-wrap",
isButtonCollapsed isButtonCollapsed
? 'justify-center gap-0 px-2 h-9' ? "justify-center gap-0 px-2 h-9"
: 'justify-start gap-2 min-h-9' : "justify-start gap-2 min-h-9",
)} )}
disabled={disabled} disabled={disabled}
> >
@ -52,13 +52,13 @@ export const SidebarButton = ({
<span <span
className={cn( className={cn(
'flex flex-wrap justify-start text-left text-wrap break-all', "flex flex-wrap justify-start text-left text-wrap break-all",
'min-w-0', "min-w-0",
'px-1', "px-1",
'transition-all duration-300 ease-in-out', "transition-all duration-300 ease-in-out",
isButtonCollapsed isButtonCollapsed
? 'opacity-0 max-w-0 invisible w-0 overflow-hidden' ? "opacity-0 max-w-0 invisible w-0 overflow-hidden"
: 'opacity-100 max-w-full visible flex-1 whitespace-normal' : "opacity-100 max-w-full visible flex-1 whitespace-normal",
)} )}
> >
{label} {label}
@ -70,7 +70,7 @@ export const SidebarButton = ({
"ml-auto flex-shrink-0 justify-end text-white text-xs rounded-full px-1.5 py-0.5 bg-red-600", "ml-auto flex-shrink-0 justify-end text-white text-xs rounded-full px-1.5 py-0.5 bg-red-600",
"flex-shrink-0", "flex-shrink-0",
"transition-opacity duration-300 ease-in-out", "transition-opacity duration-300 ease-in-out",
isButtonCollapsed ? 'opacity-0 invisible' : 'opacity-100 visible' isButtonCollapsed ? "opacity-0 invisible" : "opacity-100 visible",
)} )}
> >
{count} {count}
@ -78,4 +78,4 @@ export const SidebarButton = ({
)} )}
</Button> </Button>
); );
}; };

207
src/components/generic/Table/index.test.tsx

@ -1,111 +1,122 @@
import { describe, it, expect } from "vitest"; import { describe, expect, it } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react"; import { fireEvent, render, screen } from "@testing-library/react";
import { Table } from "@components/generic/Table/index.tsx"; import { Table } from "@components/generic/Table/index.tsx";
import { TimeAgo } from "@components/generic/TimeAgo.tsx"; import { TimeAgo } from "@components/generic/TimeAgo.tsx";
import { Mono } from "@components/generic/Mono.tsx"; import { Mono } from "@components/generic/Mono.tsx";
// @ts-types="react"
import React from "react";
describe("Generic Table", () => { describe("Generic Table", () => {
it("Can render an empty table.", () => { it("Can render an empty table.", () => {
render( render(
<Table <Table
headings={[]} headings={[]}
rows={[]} rows={[]}
/> />,
); );
expect(screen.getByRole("table")).toBeInTheDocument(); expect(screen.getByRole("table")).toBeInTheDocument();
}); });
it("Can render a table with headers and no rows.", async () => {
render(
<Table
headings={[
{ title: "", type: "blank", sortable: false },
{ title: "Short Name", type: "normal", sortable: true },
{ title: "Long Name", type: "normal", sortable: true },
{ title: "Model", type: "normal", sortable: true },
{ title: "MAC Address", type: "normal", sortable: true },
{ title: "Last Heard", type: "normal", sortable: true },
{ title: "SNR", type: "normal", sortable: true },
{ title: "Encryption", type: "normal", sortable: false },
{ title: "Connection", type: "normal", sortable: true },
]}
rows={[]}
/>,
);
await screen.findByRole("table");
expect(screen.getAllByRole("columnheader")).toHaveLength(9);
});
// A simplified version of the rows in pages/Nodes.tsx for testing purposes
const mockDevicesWithShortNameAndConnection = [
{ user: { shortName: "TST1" }, hopsAway: 1, lastHeard: Date.now() + 1000 },
{ user: { shortName: "TST2" }, hopsAway: 0, lastHeard: Date.now() + 4000 },
{ user: { shortName: "TST3" }, hopsAway: 4, lastHeard: Date.now() },
{ user: { shortName: "TST4" }, hopsAway: 3, lastHeard: Date.now() + 2000 },
];
const mockRows = mockDevicesWithShortNameAndConnection.map((node) => [
<h1 data-testshortname key={node.user.shortName}>{node.user.shortName}</h1>,
<React.Fragment key={node.user.shortName}>
<TimeAgo timestamp={node.lastHeard * 1000} />
</React.Fragment>,
<Mono key="hops" data-testhops>
{node.lastHeard !== 0
? node.hopsAway === 0
? "Direct"
: `${node.hopsAway?.toString()} ${
node.hopsAway > 1 ? "hops" : "hop"
} away`
: "-"}
</Mono>,
]);
it("Can render a table with headers and no rows.", async () => { it("Can sort rows appropriately.", async () => {
render( render(
<Table <Table
headings={[ headings={[
{ title: "", type: "blank", sortable: false }, { title: "Short Name", type: "normal", sortable: true },
{ title: "Short Name", type: "normal", sortable: true }, { title: "Last Heard", type: "normal", sortable: true },
{ title: "Long Name", type: "normal", sortable: true }, { title: "Connection", type: "normal", sortable: true },
{ title: "Model", type: "normal", sortable: true }, ]}
{ title: "MAC Address", type: "normal", sortable: true }, rows={mockRows}
{ title: "Last Heard", type: "normal", sortable: true }, />,
{ title: "SNR", type: "normal", sortable: true }, );
{ title: "Encryption", type: "normal", sortable: false }, const renderedTable = await screen.findByRole("table");
{ title: "Connection", type: "normal", sortable: true }, const columnHeaders = screen.getAllByRole("columnheader");
]} expect(columnHeaders).toHaveLength(3);
rows={[]}
/>
);
await screen.findByRole('table');
expect(screen.getAllByRole("columnheader")).toHaveLength(9);
});
// A simplified version of the rows in pages/Nodes.tsx for testing purposes // Will be sorted "Last heard" "asc" by default
const mockDevicesWithShortNameAndConnection = [ expect(
{user: {shortName: "TST1"}, hopsAway: 1, lastHeard: Date.now() + 1000 }, [...renderedTable.querySelectorAll("[data-testshortname]")]
{user: {shortName: "TST2"}, hopsAway: 0, lastHeard: Date.now() + 4000 }, .map((el) => el.textContent)
{user: {shortName: "TST3"}, hopsAway: 4, lastHeard: Date.now() }, .map((v) => v?.trim())
{user: {shortName: "TST4"}, hopsAway: 3, lastHeard: Date.now() + 2000 } .join(","),
]; )
.toMatch("TST2,TST4,TST1,TST3");
const mockRows = mockDevicesWithShortNameAndConnection.map(node => [
<h1 data-testshortname> { node.user.shortName } </h1>,
<><TimeAgo timestamp={node.lastHeard * 1000} /></>,
<Mono key="hops" data-testhops>
{node.lastHeard !== 0
? node.hopsAway === 0
? "Direct"
: `${node.hopsAway?.toString()} ${
node.hopsAway > 1 ? "hops" : "hop"
} away`
: "-"}
</Mono>
])
it("Can sort rows appropriately.", async () => { fireEvent.click(columnHeaders[0]);
render(
<Table
headings={[
{ title: "Short Name", type: "normal", sortable: true },
{ title: "Last Heard", type: "normal", sortable: true },
{ title: "Connection", type: "normal", sortable: true },
]}
rows={mockRows}
/>
);
const renderedTable = await screen.findByRole('table');
const columnHeaders = screen.getAllByRole("columnheader");
expect(columnHeaders).toHaveLength(3);
// Will be sorted "Last heard" "asc" by default // Re-sort by Short Name asc
expect( [...renderedTable.querySelectorAll('[data-testshortname]')] expect(
.map(el=>el.textContent) [...renderedTable.querySelectorAll("[data-testshortname]")]
.map(v=>v?.trim()) .map((el) => el.textContent)
.join(',')) .map((v) => v?.trim())
.toMatch('TST2,TST4,TST1,TST3'); .join(","),
)
fireEvent.click(columnHeaders[0]); .toMatch("TST1,TST2,TST3,TST4");
// Re-sort by Short Name asc fireEvent.click(columnHeaders[0]);
expect( [...renderedTable.querySelectorAll('[data-testshortname]')]
.map(el=>el.textContent)
.map(v=>v?.trim())
.join(','))
.toMatch('TST1,TST2,TST3,TST4');
fireEvent.click(columnHeaders[0]); // Re-sort by Short Name desc
expect(
[...renderedTable.querySelectorAll("[data-testshortname]")]
.map((el) => el.textContent)
.map((v) => v?.trim())
.join(","),
)
.toMatch("TST4,TST3,TST2,TST1");
// Re-sort by Short Name desc fireEvent.click(columnHeaders[2]);
expect( [...renderedTable.querySelectorAll('[data-testshortname]')]
.map(el=>el.textContent)
.map(v=>v?.trim())
.join(','))
.toMatch('TST4,TST3,TST2,TST1');
fireEvent.click(columnHeaders[2]); // Re-sort by Hops Away
expect(
// Re-sort by Hops Away [...renderedTable.querySelectorAll("[data-testshortname]")]
expect( [...renderedTable.querySelectorAll('[data-testshortname]')] .map((el) => el.textContent)
.map(el=>el.textContent) .map((v) => v?.trim())
.map(v=>v?.trim()) .join(","),
.join(',')) )
.toMatch('TST2,TST1,TST4,TST3'); .toMatch("TST2,TST1,TST4,TST3");
}); });
}) });

178
src/components/generic/Table/index.tsx

@ -1,9 +1,10 @@
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"; import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import React from "react";
export interface TableProps { export interface TableProps {
headings: Heading[]; headings: Heading[];
rows: [][]; rows: React.ReactNode[][];
} }
export interface Heading { export interface Heading {
@ -12,18 +13,15 @@ export interface Heading {
sortable: boolean; sortable: boolean;
} }
/** function numericHops(hopsAway: string | unknown): number {
* @param hopsAway String describing the number of hops away the node is from the current node if (typeof hopsAway !== "string") {
* @returns number of hopsAway or `0` if hopsAway is 'Direct' return Number.MAX_SAFE_INTEGER;
*/ }
function numericHops(hopsAway: string): number {
if (hopsAway.match(/direct/i)) { if (hopsAway.match(/direct/i)) {
return 0; return 0;
} }
if (hopsAway.match(/\d+\s+hop/gi)) { const match = hopsAway.match(/(\d+)\s+hop/i);
return Number(hopsAway.match(/(\d+)\s+hop/i)?.[1]); return Number(match?.[1] ?? Number.MAX_SAFE_INTEGER);
}
return Number.MAX_SAFE_INTEGER;
} }
export const Table = ({ headings, rows }: TableProps) => { export const Table = ({ headings, rows }: TableProps) => {
@ -39,45 +37,63 @@ export const Table = ({ headings, rows }: TableProps) => {
} }
}; };
const getElement = (cell: React.ReactNode): React.ReactElement | null => {
if (!React.isValidElement(cell)) {
return null;
}
if (cell.type === React.Fragment) {
const childrenArray = React.Children.toArray(cell.props.children);
const firstElement = childrenArray.find((child) =>
React.isValidElement(child)
);
return (firstElement as React.ReactElement) ?? null;
}
// If not a fragment, return the element itself
return cell;
};
const sortedRows = rows.slice().sort((a, b) => { const sortedRows = rows.slice().sort((a, b) => {
if (!sortColumn) return 0; if (!sortColumn) return 0;
const columnIndex = headings.findIndex((h) => h.title === sortColumn); const columnIndex = headings.findIndex((h) => h.title === sortColumn);
const aValue = a[columnIndex].props.children; if (columnIndex === -1) return 0;
const bValue = b[columnIndex].props.children;
const elementA = getElement(a[columnIndex]);
const elementB = getElement(b[columnIndex]);
if (sortColumn === "Last Heard") { if (sortColumn === "Last Heard") {
const aTimestamp = aValue.props.timestamp ?? 0; const aTimestamp = elementA?.props?.timestamp ?? 0;
const bTimestamp = bValue.props.timestamp ?? 0; const bTimestamp = elementB?.props?.timestamp ?? 0;
if (aTimestamp < bTimestamp) return sortOrder === "asc" ? -1 : 1;
if (aTimestamp < bTimestamp) { if (aTimestamp > bTimestamp) return sortOrder === "asc" ? 1 : -1;
return sortOrder === "asc" ? -1 : 1;
}
if (aTimestamp > bTimestamp) {
return sortOrder === "asc" ? 1 : -1;
}
return 0; return 0;
} }
if (sortColumn === "Connection") { if (sortColumn === "Connection") {
const aNumHops = numericHops(aValue instanceof Array ? aValue[0] : aValue); const aHopsStr = elementA?.props?.children;
const bNumHops = numericHops(bValue instanceof Array ? bValue[0] : bValue); const bHopsStr = elementB?.props?.children;
const aNumHops = numericHops(aHopsStr);
if (aNumHops < bNumHops) { const bNumHops = numericHops(bHopsStr);
return sortOrder === "asc" ? -1 : 1; if (aNumHops < bNumHops) return sortOrder === "asc" ? -1 : 1;
} if (aNumHops > bNumHops) return sortOrder === "asc" ? 1 : -1;
if (aNumHops > bNumHops) {
return sortOrder === "asc" ? 1 : -1;
}
return 0; return 0;
} }
if (aValue < bValue) { const aValue = elementA?.props?.children;
return sortOrder === "asc" ? -1 : 1; const bValue = elementB?.props?.children;
} const valA = aValue ?? "";
if (aValue > bValue) { const valB = bValue ?? "";
return sortOrder === "asc" ? 1 : -1;
} // Ensure consistent comparison for potentially different types
const compareA = typeof valA === "string" || typeof valA === "number"
? valA
: String(valA);
const compareB = typeof valB === "string" || typeof valB === "number"
? valB
: String(valB);
if (compareA < compareB) return sortOrder === "asc" ? -1 : 1;
if (compareA > compareB) return sortOrder === "asc" ? 1 : -1;
return 0; return 0;
}); });
@ -89,48 +105,76 @@ export const Table = ({ headings, rows }: TableProps) => {
<th <th
key={heading.title} key={heading.title}
scope="col" scope="col"
className={`py-2 pr-3 text-left ${heading.sortable className={`py-2 pr-3 text-left ${
? "cursor-pointer hover:brightness-hover active:brightness-press" heading.sortable
: "" ? "cursor-pointer hover:brightness-hover active:brightness-press"
}`} : ""
}`}
onClick={() => heading.sortable && headingSort(heading.title)} onClick={() => heading.sortable && headingSort(heading.title)}
onKeyUp={() => heading.sortable && headingSort(heading.title)} onKeyUp={(e) => {
if (heading.sortable && (e.key === "Enter" || e.key === " ")) {
headingSort(heading.title);
}
}}
tabIndex={heading.sortable ? 0 : -1}
role="columnheader"
aria-sort={sortColumn === heading.title
? sortOrder === "asc" ? "ascending" : "descending"
: "none"}
> >
<div className="flex gap-2"> <div className="flex items-center gap-2">
{heading.title} {heading.title}
{sortColumn === heading.title && {heading.sortable && sortColumn === heading.title && (
(sortOrder === "asc" sortOrder === "asc"
? <ChevronUpIcon size={16} /> ? <ChevronUpIcon size={16} aria-hidden="true" />
: <ChevronDownIcon size={16} />)} : <ChevronDownIcon size={16} aria-hidden="true" />
)}
</div> </div>
</th> </th>
))} ))}
</tr> </tr>
</thead> </thead>
<tbody className="max-w-fit"> <tbody className="max-w-fit">
{sortedRows.map((row, index) => { {sortedRows.map((row) => {
// biome-ignore lint/suspicious/noArrayIndexKey: TODO: Once this table is sortable, this should get fixed. const firstCellKey =
return (<tr key={index} className={`${index % 2 ? 'bg-white dark:bg-white/2' : 'bg-slate-50/50 dark:bg-slate-50/5'} border-b-1 border-slate-200 dark:border-slate-900`}> (React.isValidElement(row[0]) && row[0].key !== null)
{row.map((item, index) => { ? String(row[0].key)
return (index === 0 ? : null;
<th const rowKey = firstCellKey ?? Math.random().toString(); // Use random only as last resort
key={item.key ?? index}
className="whitespace-nowrap py-2 text-sm text-text-secondary first:pl-2" return (
scope="row" <tr
> key={rowKey}
{item} className={`
</th> : bg-white dark:bg-white/10
<td odd:bg-slate-800/70 dark:even:bg-slate-900/70
key={item.key ?? index} `}
className="whitespace-nowrap py-2 text-sm text-text-secondary first:pl-2" >
> {row.map((item, cellIndex) => {
{item} const cellKey = `${rowKey}_${cellIndex}`;
</td>) return cellIndex === 0
})} ? (
</tr> <th
key={cellKey}
className="whitespace-nowrap px-3 py-2 text-sm text-left text-text-secondary"
scope="row"
>
{item}
</th>
)
: (
<td
key={cellKey}
className="whitespace-nowrap px-3 py-2 text-sm text-text-secondary"
>
{item}
</td>
);
})}
</tr>
); );
})} })}
</tbody> </tbody>
</table> </table>
); );
}; };

13
src/core/dto/NodeNumToNodeInfoDTO.ts

@ -3,7 +3,7 @@ import { Protobuf } from "@meshtastic/core";
class NodeInfoFactory { class NodeInfoFactory {
private static createDefaultUser(num: number): Protobuf.Mesh.User { private static createDefaultUser(num: number): Protobuf.Mesh.User {
const userIdHex = num.toString(16).toUpperCase().padStart(2, '0'); const userIdHex = num.toString(16).toUpperCase().padStart(2, "0");
const userId = `!${userIdHex}`; const userId = `!${userIdHex}`;
const last4 = userIdHex.slice(-4); const last4 = userIdHex.slice(-4);
const longName = `Meshtastic ${last4}`; const longName = `Meshtastic ${last4}`;
@ -19,14 +19,19 @@ class NodeInfoFactory {
}); });
} }
public static ensureDefaultUser(node: Protobuf.Mesh.NodeInfo): Protobuf.Mesh.NodeInfo { public static ensureDefaultUser(
node: Protobuf.Mesh.NodeInfo,
): Protobuf.Mesh.NodeInfo {
if (!node) { if (!node) {
return node; return node;
} }
if (!node.user) { if (!node.user) {
if (node.num === undefined || node.num === null) { if (node.num === undefined || node.num === null) {
console.error(`NodeInfoFactory.ensureDefaultUser: Cannot create default user for node because 'num' is missing.`, node); console.error(
`NodeInfoFactory.ensureDefaultUser: Cannot create default user for node because 'num' is missing.`,
node,
);
return node; return node;
} }
@ -37,4 +42,4 @@ class NodeInfoFactory {
} }
} }
export default NodeInfoFactory; export default NodeInfoFactory;

24
src/core/dto/PacketToMessageDTO.ts

@ -1,5 +1,9 @@
import type { Types } from "@meshtastic/js"; import type { Types } from "@meshtastic/js";
import { Message, MessageType, MessageState } from "../stores/messageStore/index.ts"; import {
Message,
MessageState,
MessageType,
} from "../stores/messageStore/index.ts";
class PacketToMessageDTO { class PacketToMessageDTO {
channel: Types.ChannelNumber; channel: Types.ChannelNumber;
@ -16,9 +20,13 @@ class PacketToMessageDTO {
this.to = data.to; this.to = data.to;
this.from = data.from; this.from = data.from;
this.messageId = data.id; this.messageId = data.id;
this.state = data.from !== nodeNum ? MessageState.Ack : MessageState.Waiting; this.state = data.from !== nodeNum
? MessageState.Ack
: MessageState.Waiting;
this.message = data.data; this.message = data.data;
this.type = (data.type === 'direct') ? MessageType.Direct : MessageType.Broadcast; this.type = (data.type === "direct")
? MessageType.Direct
: MessageType.Broadcast;
let dateTimestamp = Date.now(); let dateTimestamp = Date.now();
if (data.rxTime instanceof Date) { if (data.rxTime instanceof Date) {
@ -27,9 +35,11 @@ class PacketToMessageDTO {
if (!isNaN(timeValue)) { if (!isNaN(timeValue)) {
dateTimestamp = timeValue; dateTimestamp = timeValue;
} }
} } else if (data.rxTime != null) {
else if (data.rxTime != null) { console.warn(
console.warn(`Received rxTime in PacketToMessageDTO was not a Date object as expected (type: ${typeof data.rxTime}, value: ${data.rxTime}). Using current time as fallback.`); `Received rxTime in PacketToMessageDTO was not a Date object as expected (type: ${typeof data
.rxTime}, value: ${data.rxTime}). Using current time as fallback.`,
);
} }
this.date = dateTimestamp; this.date = dateTimestamp;
} }
@ -48,4 +58,4 @@ class PacketToMessageDTO {
} }
} }
export default PacketToMessageDTO; export default PacketToMessageDTO;

12
src/core/hooks/useCopyToClipboard.ts

@ -4,7 +4,9 @@ interface UseCopyToClipboardProps {
timeout?: number; timeout?: number;
} }
export function useCopyToClipboard({ timeout = 2000 }: UseCopyToClipboardProps = {}) { export function useCopyToClipboard(
{ timeout = 2000 }: UseCopyToClipboardProps = {},
) {
const [isCopied, setIsCopied] = useState<boolean>(false); const [isCopied, setIsCopied] = useState<boolean>(false);
const timeoutRef = useRef<number | null>(null); const timeoutRef = useRef<number | null>(null);
@ -19,7 +21,7 @@ export function useCopyToClipboard({ timeout = 2000 }: UseCopyToClipboardProps =
const copy = useCallback( const copy = useCallback(
async (text: string) => { async (text: string) => {
if (!navigator?.clipboard) { if (!navigator?.clipboard) {
console.warn('Clipboard API not available'); console.warn("Clipboard API not available");
setIsCopied(false); setIsCopied(false);
return false; return false;
} }
@ -39,13 +41,13 @@ export function useCopyToClipboard({ timeout = 2000 }: UseCopyToClipboardProps =
return true; return true;
} catch (error) { } catch (error) {
console.error('Failed to copy text to clipboard:', error); console.error("Failed to copy text to clipboard:", error);
setIsCopied(false); setIsCopied(false);
return false; return false;
} }
}, },
[timeout] [timeout],
); );
return { isCopied, copy }; return { isCopied, copy };
} }

13
src/core/hooks/useKeyBackupReminder.tsx

@ -33,14 +33,16 @@ function isReminderExpired(expires?: string): boolean {
export function useBackupReminder({ export function useBackupReminder({
enabled, enabled,
message, message,
onAccept = () => { }, onAccept = () => {},
reminderInDays = REMINDER_DAYS_ONE_WEEK, reminderInDays = REMINDER_DAYS_ONE_WEEK,
}: UseBackupReminderOptions) { }: UseBackupReminderOptions) {
const { toast } = useToast(); const { toast } = useToast();
const toastShownRef = useRef(false); const toastShownRef = useRef(false);
const [reminderState, setReminderState] = useLocalStorage<ReminderState | null>( const [reminderState, setReminderState] = useLocalStorage<
ReminderState | null
>(
STORAGE_KEY, STORAGE_KEY,
null null,
); );
const setReminderExpiry = useCallback((days: number) => { const setReminderExpiry = useCallback((days: number) => {
@ -73,7 +75,7 @@ export function useBackupReminder({
setReminderExpiry(reminderInDays); setReminderExpiry(reminderInDays);
}} }}
> >
Remind me in {reminderInDays} day{reminderInDays > 1 ? 's' : ''} Remind me in {reminderInDays} day{reminderInDays > 1 ? "s" : ""}
</Button> </Button>
<Button <Button
type="button" type="button"
@ -108,6 +110,5 @@ export function useBackupReminder({
enabled, enabled,
message, message,
onAccept, onAccept,
]); ]);
}; }

78
src/core/hooks/useLocalStorage.test.ts

@ -1,52 +1,52 @@
import { renderHook, act } from '@testing-library/react' import { act, renderHook } from "@testing-library/react";
import useLocalStorage from './useLocalStorage' import useLocalStorage from "./useLocalStorage.ts";
import { beforeEach, describe, expect, it } from "vitest"; import { beforeEach, describe, expect, it } from "vitest";
describe('useLocalStorage', () => { describe("useLocalStorage", () => {
const key = 'test-key' const key = "test-key";
beforeEach(() => { beforeEach(() => {
localStorage.clear() localStorage.clear();
}) });
it('should initialize with initial value if localStorage is empty', () => { it("should initialize with initial value if localStorage is empty", () => {
const { result } = renderHook(() => useLocalStorage(key, 'initial')) const { result } = renderHook(() => useLocalStorage(key, "initial"));
const [value] = result.current const [value] = result.current;
expect(value).toBe('initial') expect(value).toBe("initial");
}) });
it('should read existing value from localStorage', () => { it("should read existing value from localStorage", () => {
localStorage.setItem(key, JSON.stringify('stored')) localStorage.setItem(key, JSON.stringify("stored"));
const { result } = renderHook(() => useLocalStorage(key, 'initial')) const { result } = renderHook(() => useLocalStorage(key, "initial"));
const [value] = result.current const [value] = result.current;
expect(value).toBe('stored') expect(value).toBe("stored");
}) });
it('should update localStorage when setValue is called', () => { it("should update localStorage when setValue is called", () => {
const { result } = renderHook(() => useLocalStorage(key, 'initial')) const { result } = renderHook(() => useLocalStorage(key, "initial"));
const [, setValue] = result.current const [, setValue] = result.current;
act(() => { act(() => {
setValue('updated') setValue("updated");
}) });
expect(localStorage.getItem(key)).toBe(JSON.stringify('updated')) expect(localStorage.getItem(key)).toBe(JSON.stringify("updated"));
expect(result.current[0]).toBe('updated') expect(result.current[0]).toBe("updated");
}) });
it('should remove value from localStorage when removeValue is called', () => { it("should remove value from localStorage when removeValue is called", () => {
const { result } = renderHook(() => useLocalStorage(key, 'initial')) const { result } = renderHook(() => useLocalStorage(key, "initial"));
const [, setValue, removeValue] = result.current const [, setValue, removeValue] = result.current;
act(() => { act(() => {
setValue('to-be-removed') setValue("to-be-removed");
}) });
act(() => { act(() => {
removeValue() removeValue();
}) });
expect(localStorage.getItem(key)).toBeNull() expect(localStorage.getItem(key)).toBeNull();
expect(result.current[0]).toBe('initial') expect(result.current[0]).toBe("initial");
}) });
}) });

27
src/core/hooks/useLocalStorage.ts

@ -55,8 +55,9 @@ export default function useLocalStorage<T>(
return undefined as unknown as T; return undefined as unknown as T;
} }
const defaultValue = const defaultValue = initialValue instanceof Function
initialValue instanceof Function ? initialValue() : initialValue; ? initialValue()
: initialValue;
let parsed: unknown; let parsed: unknown;
try { try {
@ -74,8 +75,9 @@ export default function useLocalStorage<T>(
// Get from local storage then // Get from local storage then
// parse stored json or return initialValue // parse stored json or return initialValue
const readValue = useCallback((): T => { const readValue = useCallback((): T => {
const initialValueToUse = const initialValueToUse = initialValue instanceof Function
initialValue instanceof Function ? initialValue() : initialValue; ? initialValue()
: initialValue;
// Prevent build error "window is undefined" but keep working // Prevent build error "window is undefined" but keep working
if (IS_SERVER) { if (IS_SERVER) {
@ -83,7 +85,7 @@ export default function useLocalStorage<T>(
} }
try { try {
const raw = window.localStorage.getItem(key); const raw = globalThis.localStorage.getItem(key);
return raw ? deserializer(raw) : initialValueToUse; return raw ? deserializer(raw) : initialValueToUse;
} catch (error) { } catch (error) {
console.warn(`Error reading localStorage key “${key}”:`, error); console.warn(`Error reading localStorage key “${key}”:`, error);
@ -115,13 +117,13 @@ export default function useLocalStorage<T>(
const newValue = value instanceof Function ? value(readValue()) : value; const newValue = value instanceof Function ? value(readValue()) : value;
// Save to local storage // Save to local storage
window.localStorage.setItem(key, serializer(newValue)); globalThis.localStorage.setItem(key, serializer(newValue));
// Save state // Save state
setStoredValue(newValue); setStoredValue(newValue);
// We dispatch a custom event so every similar useLocalStorage hook is notified // We dispatch a custom event so every similar useLocalStorage hook is notified
window.dispatchEvent(new StorageEvent("local-storage", { key })); globalThis.dispatchEvent(new StorageEvent("local-storage", { key }));
} catch (error) { } catch (error) {
console.warn(`Error setting localStorage key “${key}”:`, error); console.warn(`Error setting localStorage key “${key}”:`, error);
} }
@ -137,17 +139,18 @@ export default function useLocalStorage<T>(
); );
} }
const defaultValue = const defaultValue = initialValue instanceof Function
initialValue instanceof Function ? initialValue() : initialValue; ? initialValue()
: initialValue;
// Remove the key from local storage // Remove the key from local storage
window.localStorage.removeItem(key); globalThis.localStorage.removeItem(key);
// Save state with default value // Save state with default value
setStoredValue(defaultValue); setStoredValue(defaultValue);
// We dispatch a custom event so every similar useLocalStorage hook is notified // We dispatch a custom event so every similar useLocalStorage hook is notified
window.dispatchEvent(new StorageEvent("local-storage", { key })); globalThis.dispatchEvent(new StorageEvent("local-storage", { key }));
}, [key]); }, [key]);
useEffect(() => { useEffect(() => {
@ -176,4 +179,4 @@ export default function useLocalStorage<T>(
}, []); }, []);
return [storedValue, setValue, removeValue]; return [storedValue, setValue, removeValue];
} }

28
src/core/hooks/usePasswordVisibilityToggle.test.ts

@ -1,22 +1,22 @@
import { renderHook, act } from '@testing-library/react'; import { act, renderHook } from "@testing-library/react";
import { describe, it, expect } from 'vitest'; import { describe, expect, it } from "vitest";
import { usePasswordVisibilityToggle } from './usePasswordVisibilityToggle.ts'; import { usePasswordVisibilityToggle } from "./usePasswordVisibilityToggle.ts";
describe('usePasswordVisibilityToggle Hook', () => { describe("usePasswordVisibilityToggle Hook", () => {
it('should initialize with visibility set to false by default', () => { it("should initialize with visibility set to false by default", () => {
const { result } = renderHook(() => usePasswordVisibilityToggle()); const { result } = renderHook(() => usePasswordVisibilityToggle());
expect(result.current.isVisible).toBe(false); expect(result.current.isVisible).toBe(false);
expect(typeof result.current.toggleVisibility).toBe('function'); expect(typeof result.current.toggleVisibility).toBe("function");
}); });
it('should initialize with visibility set to true if initialVisible is true', () => { it("should initialize with visibility set to true if initialVisible is true", () => {
const { result } = renderHook(() => const { result } = renderHook(() =>
usePasswordVisibilityToggle({ initialVisible: true }) usePasswordVisibilityToggle({ initialVisible: true })
); );
expect(result.current.isVisible).toBe(true); expect(result.current.isVisible).toBe(true);
}); });
it('should toggle visibility from false to true when toggleVisibility is called', () => { it("should toggle visibility from false to true when toggleVisibility is called", () => {
const { result } = renderHook(() => usePasswordVisibilityToggle()); const { result } = renderHook(() => usePasswordVisibilityToggle());
expect(result.current.isVisible).toBe(false); expect(result.current.isVisible).toBe(false);
act(() => { act(() => {
@ -25,7 +25,7 @@ describe('usePasswordVisibilityToggle Hook', () => {
expect(result.current.isVisible).toBe(true); expect(result.current.isVisible).toBe(true);
}); });
it('should toggle visibility from true to false when toggleVisibility is called', () => { it("should toggle visibility from true to false when toggleVisibility is called", () => {
const { result } = renderHook(() => const { result } = renderHook(() =>
usePasswordVisibilityToggle({ initialVisible: true }) usePasswordVisibilityToggle({ initialVisible: true })
); );
@ -36,7 +36,7 @@ describe('usePasswordVisibilityToggle Hook', () => {
expect(result.current.isVisible).toBe(false); expect(result.current.isVisible).toBe(false);
}); });
it('should toggle visibility correctly multiple times', () => { it("should toggle visibility correctly multiple times", () => {
const { result } = renderHook(() => usePasswordVisibilityToggle()); const { result } = renderHook(() => usePasswordVisibilityToggle());
expect(result.current.isVisible).toBe(false); expect(result.current.isVisible).toBe(false);
act(() => { act(() => {
@ -53,8 +53,10 @@ describe('usePasswordVisibilityToggle Hook', () => {
expect(result.current.isVisible).toBe(true); expect(result.current.isVisible).toBe(true);
}); });
it('should return a stable toggleVisibility function reference (due to useCallback)', () => { it("should return a stable toggleVisibility function reference (due to useCallback)", () => {
const { result, rerender } = renderHook(() => usePasswordVisibilityToggle()); const { result, rerender } = renderHook(() =>
usePasswordVisibilityToggle()
);
const initialToggleFunc = result.current.toggleVisibility; const initialToggleFunc = result.current.toggleVisibility;
rerender(); rerender();
expect(result.current.toggleVisibility).toBe(initialToggleFunc); expect(result.current.toggleVisibility).toBe(initialToggleFunc);
@ -63,4 +65,4 @@ describe('usePasswordVisibilityToggle Hook', () => {
}); });
expect(result.current.isVisible).toBe(true); expect(result.current.isVisible).toBe(true);
}); });
}); });

14
src/core/hooks/usePasswordVisibilityToggle.ts

@ -1,4 +1,4 @@
import { useState, useCallback } from 'react'; import { useCallback, useState } from "react";
interface UsePasswordVisibilityToggleProps { interface UsePasswordVisibilityToggleProps {
initialVisible?: boolean; initialVisible?: boolean;
@ -6,15 +6,17 @@ interface UsePasswordVisibilityToggleProps {
/** /**
* Manages the state for toggling password visibility. * Manages the state for toggling password visibility.
* *
* @param {boolean} [options.initialVisible=false] * @param {boolean} [options.initialVisible=false]
* @returns {{isVisible: boolean, toggleVisibility: () => void}} * @returns {{isVisible: boolean, toggleVisibility: () => void}}
*/ */
export function usePasswordVisibilityToggle({ initialVisible = false }: UsePasswordVisibilityToggleProps = {}) { export function usePasswordVisibilityToggle(
{ initialVisible = false }: UsePasswordVisibilityToggleProps = {},
) {
const [isVisible, setIsVisible] = useState<boolean>(initialVisible); const [isVisible, setIsVisible] = useState<boolean>(initialVisible);
const toggleVisibility = useCallback(() => { const toggleVisibility = useCallback(() => {
setIsVisible(prev => !prev); setIsVisible((prev) => !prev);
}, []); }, []);
return { isVisible, toggleVisibility }; return { isVisible, toggleVisibility };
} }

11
src/core/hooks/usePinnedItems.test.ts

@ -1,12 +1,12 @@
import { renderHook, act } from "@testing-library/react"; import { act, renderHook } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { usePinnedItems } from "./usePinnedItems.ts"; import { usePinnedItems } from "./usePinnedItems.ts";
const mockSetPinnedItems = vi.fn(); const mockSetPinnedItems = vi.fn();
const mockUseLocalStorage = vi.fn(); const mockUseLocalStorage = vi.fn();
vi.mock("@core/hooks/useLocalStorage.ts", () => ({ vi.mock("@core/hooks/useLocalStorage.ts", () => ({
default: (...args: any[]) => mockUseLocalStorage(...args), default: (...args) => mockUseLocalStorage(...args),
})); }));
describe("usePinnedItems", () => { describe("usePinnedItems", () => {
@ -45,7 +45,10 @@ describe("usePinnedItems", () => {
}); });
it("removes an item if it's already pinned", () => { it("removes an item if it's already pinned", () => {
mockUseLocalStorage.mockReturnValue([["item1", "item2"], mockSetPinnedItems]); mockUseLocalStorage.mockReturnValue([
["item1", "item2"],
mockSetPinnedItems,
]);
const { result } = renderHook(() => const { result } = renderHook(() =>
usePinnedItems({ storageName: "test-storage" }) usePinnedItems({ storageName: "test-storage" })

9
src/core/hooks/usePinnedItems.ts

@ -2,13 +2,14 @@ import useLocalStorage from "@core/hooks/useLocalStorage.ts";
import { useCallback } from "react"; import { useCallback } from "react";
export function usePinnedItems({ storageName }: { storageName: string }) { export function usePinnedItems({ storageName }: { storageName: string }) {
const [pinnedItems, setPinnedItems] = useLocalStorage<string[]>(storageName, []); const [pinnedItems, setPinnedItems] = useLocalStorage<string[]>(
storageName,
[],
);
const togglePinnedItem = useCallback((label: string) => { const togglePinnedItem = useCallback((label: string) => {
setPinnedItems((prev) => setPinnedItems((prev) =>
prev.includes(label) prev.includes(label) ? prev.filter((g) => g !== label) : [...prev, label]
? prev.filter((g) => g !== label)
: [...prev, label]
); );
}, []); }, []);

108
src/core/hooks/useToast.test.tsx

@ -1,81 +1,79 @@
import { renderHook, act } from '@testing-library/react' import { act, renderHook } from "@testing-library/react";
import { useToast } from "@core/hooks/useToast.ts" import { useToast } from "@core/hooks/useToast.ts";
import { Button } from '@components/UI/Button.tsx' import { Button } from "@components/UI/Button.tsx";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
describe('useToast', () => { describe("useToast", () => {
beforeEach(() => { beforeEach(() => {
// Reset toast memory state before each test // Reset toast memory state before each test
// our hook uses global memory to store toasts // our hook uses global memory to store toasts
// @ts-expect-error - internal test reset // @ts-expect-error - internal test reset
globalThis.memoryState = { toasts: [] } globalThis.memoryState = { toasts: [] };
vi.useFakeTimers() vi.useFakeTimers();
}) });
afterEach(() => { afterEach(() => {
vi.useRealTimers() vi.useRealTimers();
}) });
it('should create a toast with title, description, and action', () => { it("should create a toast with title, description, and action", () => {
const { result } = renderHook(() => useToast()) const { result } = renderHook(() => useToast());
act(() => { act(() => {
result.current.toast({ result.current.toast({
title: 'Backup Reminder', title: "Backup Reminder",
description: 'Don\'t forget to backup!', description: "Don't forget to backup!",
action: <Button>Backup Now</Button> action: <Button>Backup Now</Button>,
}) });
vi.runAllTimers() vi.runAllTimers();
}) });
const toast = result.current.toasts[0] const toast = result.current.toasts[0];
expect(result.current.toasts.length).toBe(1) expect(result.current.toasts.length).toBe(1);
expect(toast.title).toBe('Backup Reminder') expect(toast.title).toBe("Backup Reminder");
expect(toast.description).toBe('Don\'t forget to backup!') expect(toast.description).toBe("Don't forget to backup!");
expect(toast.action).toBeTruthy() expect(toast.action).toBeTruthy();
expect(toast.open).toBe(true) expect(toast.open).toBe(true);
}) });
it('should dismiss a toast using returned dismiss function', () => { it("should dismiss a toast using returned dismiss function", () => {
const { result } = renderHook(() => useToast()) const { result } = renderHook(() => useToast());
vi.useFakeTimers() vi.useFakeTimers();
let toastRef: { id: string, dismiss: () => void } let toastRef: { id: string; dismiss: () => void };
act(() => { act(() => {
toastRef = result.current.toast({ title: 'Dismiss Me' }) toastRef = result.current.toast({ title: "Dismiss Me" });
vi.runAllTimers() // Flush ADD_TOAST vi.runAllTimers(); // Flush ADD_TOAST
}) });
act(() => { act(() => {
toastRef.dismiss() toastRef.dismiss();
}) });
const toast = result.current.toasts.find(t => t.id === toastRef.id) const toast = result.current.toasts.find((t) => t.id === toastRef.id);
expect(toast?.open).toBe(false) expect(toast?.open).toBe(false);
vi.useRealTimers() vi.useRealTimers();
}) });
it("should allow dismiss via hook dismiss function", () => {
const { result } = renderHook(() => useToast());
vi.useFakeTimers();
it('should allow dismiss via hook dismiss function', () => { let toastRef: { id: string };
const { result } = renderHook(() => useToast())
vi.useFakeTimers()
let toastRef: { id: string }
act(() => { act(() => {
toastRef = result.current.toast({ title: 'Manual Dismiss' }) toastRef = result.current.toast({ title: "Manual Dismiss" });
vi.runAllTimers() vi.runAllTimers();
}) });
act(() => { act(() => {
result.current.dismiss(toastRef.id) result.current.dismiss(toastRef.id);
}) });
const toast = result.current.toasts.find(t => t.id === toastRef.id)
expect(toast?.open).toBe(false)
vi.useRealTimers() const toast = result.current.toasts.find((t) => t.id === toastRef.id);
}) expect(toast?.open).toBe(false);
}) vi.useRealTimers();
});
});

154
src/core/stores/deviceStore.ts

@ -17,7 +17,7 @@ export type DialogVariant = keyof Device["dialog"];
type NodeError = { type NodeError = {
node: number; node: number;
error: string; error: string;
} };
export interface Device { export interface Device {
id: number; id: number;
@ -92,7 +92,6 @@ export interface Device {
) => Protobuf.Mesh.NodeInfo[]; ) => Protobuf.Mesh.NodeInfo[];
getNodesLength: () => number; getNodesLength: () => number;
getNode: (nodeNum: number) => Protobuf.Mesh.NodeInfo | undefined; getNode: (nodeNum: number) => Protobuf.Mesh.NodeInfo | undefined;
} }
export interface DeviceState { export interface DeviceState {
@ -110,7 +109,6 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
remoteDevices: new Map(), remoteDevices: new Map(),
addDevice: (id: number) => { addDevice: (id: number) => {
set( set(
produce<DeviceState>((draft) => { produce<DeviceState>((draft) => {
draft.devices.set(id, { draft.devices.set(id, {
@ -164,14 +162,37 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
const device = draft.devices.get(id); const device = draft.devices.get(id);
if (device) { if (device) {
switch (config.payloadVariant.case) { switch (config.payloadVariant.case) {
case "device": { device.config.device = config.payloadVariant.value; break; } case "device": {
case "position": { device.config.position = config.payloadVariant.value; break; } device.config.device = config.payloadVariant.value;
case "power": { device.config.power = config.payloadVariant.value; break; } break;
case "network": { device.config.network = config.payloadVariant.value; break; } }
case "display": { device.config.display = config.payloadVariant.value; break; } case "position": {
case "lora": { device.config.lora = config.payloadVariant.value; break; } device.config.position = config.payloadVariant.value;
case "bluetooth": { device.config.bluetooth = config.payloadVariant.value; break; } break;
case "security": { device.config.security = config.payloadVariant.value; } }
case "power": {
device.config.power = config.payloadVariant.value;
break;
}
case "network": {
device.config.network = config.payloadVariant.value;
break;
}
case "display": {
device.config.display = config.payloadVariant.value;
break;
}
case "lora": {
device.config.lora = config.payloadVariant.value;
break;
}
case "bluetooth": {
device.config.bluetooth = config.payloadVariant.value;
break;
}
case "security": {
device.config.security = config.payloadVariant.value;
}
} }
} }
}), }),
@ -183,18 +204,63 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
const device = draft.devices.get(id); const device = draft.devices.get(id);
if (device) { if (device) {
switch (config.payloadVariant.case) { switch (config.payloadVariant.case) {
case "mqtt": { device.moduleConfig.mqtt = config.payloadVariant.value; break; } case "mqtt": {
case "serial": { device.moduleConfig.serial = config.payloadVariant.value; break; } device.moduleConfig.mqtt = config.payloadVariant.value;
case "externalNotification": { device.moduleConfig.externalNotification = config.payloadVariant.value; break; } break;
case "storeForward": { device.moduleConfig.storeForward = config.payloadVariant.value; break; } }
case "rangeTest": { device.moduleConfig.rangeTest = config.payloadVariant.value; break; } case "serial": {
case "telemetry": { device.moduleConfig.telemetry = config.payloadVariant.value; break; } device.moduleConfig.serial = config.payloadVariant.value;
case "cannedMessage": { device.moduleConfig.cannedMessage = config.payloadVariant.value; break; } break;
case "audio": { device.moduleConfig.audio = config.payloadVariant.value; break; } }
case "neighborInfo": { device.moduleConfig.neighborInfo = config.payloadVariant.value; break; } case "externalNotification": {
case "ambientLighting": { device.moduleConfig.ambientLighting = config.payloadVariant.value; break; } device.moduleConfig.externalNotification =
case "detectionSensor": { device.moduleConfig.detectionSensor = config.payloadVariant.value; break; } config.payloadVariant.value;
case "paxcounter": { device.moduleConfig.paxcounter = config.payloadVariant.value; break; } break;
}
case "storeForward": {
device.moduleConfig.storeForward =
config.payloadVariant.value;
break;
}
case "rangeTest": {
device.moduleConfig.rangeTest =
config.payloadVariant.value;
break;
}
case "telemetry": {
device.moduleConfig.telemetry =
config.payloadVariant.value;
break;
}
case "cannedMessage": {
device.moduleConfig.cannedMessage =
config.payloadVariant.value;
break;
}
case "audio": {
device.moduleConfig.audio = config.payloadVariant.value;
break;
}
case "neighborInfo": {
device.moduleConfig.neighborInfo =
config.payloadVariant.value;
break;
}
case "ambientLighting": {
device.moduleConfig.ambientLighting =
config.payloadVariant.value;
break;
}
case "detectionSensor": {
device.moduleConfig.detectionSensor =
config.payloadVariant.value;
break;
}
case "paxcounter": {
device.moduleConfig.paxcounter =
config.payloadVariant.value;
break;
}
} }
} }
}), }),
@ -216,13 +282,17 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
}), }),
); );
}, },
setWorkingModuleConfig: (moduleConfig: Protobuf.ModuleConfig.ModuleConfig) => { setWorkingModuleConfig: (
moduleConfig: Protobuf.ModuleConfig.ModuleConfig,
) => {
set( set(
produce<DeviceState>((draft) => { produce<DeviceState>((draft) => {
const device = draft.devices.get(id); const device = draft.devices.get(id);
if (!device) return; if (!device) return;
const index = device.workingModuleConfig.findIndex( const index = device.workingModuleConfig.findIndex(
(wmc) => wmc.payloadVariant.case === moduleConfig.payloadVariant.case, (wmc) =>
wmc.payloadVariant.case ===
moduleConfig.payloadVariant.case,
); );
if (index !== -1) { if (index !== -1) {
device.workingModuleConfig[index] = moduleConfig; device.workingModuleConfig[index] = moduleConfig;
@ -277,7 +347,9 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
produce<DeviceState>((draft) => { produce<DeviceState>((draft) => {
const device = draft.devices.get(id); const device = draft.devices.get(id);
if (device) { if (device) {
const index = device.waypoints.findIndex((wp) => wp.id === waypoint.id); const index = device.waypoints.findIndex((wp) =>
wp.id === waypoint.id
);
if (index !== -1) { if (index !== -1) {
device.waypoints[index] = waypoint; device.waypoints[index] = waypoint;
} else { } else {
@ -288,8 +360,6 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
); );
}, },
addNodeInfo: (nodeInfo) => { addNodeInfo: (nodeInfo) => {
console.log("Node Info", nodeInfo);
set( set(
produce<DeviceState>((draft) => { produce<DeviceState>((draft) => {
const device = draft.devices.get(id); const device = draft.devices.get(id);
@ -316,11 +386,12 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
if (!device) { if (!device) {
return; return;
} }
const currentNode = device.nodesMap.get(user.from) ?? create(Protobuf.Mesh.NodeInfoSchema); const currentNode = device.nodesMap.get(user.from) ??
create(Protobuf.Mesh.NodeInfoSchema);
currentNode.user = user.data; currentNode.user = user.data;
currentNode.num = user.from; currentNode.num = user.from;
device.nodesMap.set(user.from, currentNode); device.nodesMap.set(user.from, currentNode);
}) }),
); );
}, },
addPosition: (position) => { addPosition: (position) => {
@ -330,11 +401,12 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
if (!device) { if (!device) {
return; return;
} }
const currentNode = device.nodesMap.get(position.from) ?? create(Protobuf.Mesh.NodeInfoSchema); const currentNode = device.nodesMap.get(position.from) ??
create(Protobuf.Mesh.NodeInfoSchema);
currentNode.position = position.data; currentNode.position = position.data;
currentNode.num = position.from; currentNode.num = position.from;
device.nodesMap.set(position.from, currentNode); device.nodesMap.set(position.from, currentNode);
}) }),
); );
}, },
addConnection: (connection) => { addConnection: (connection) => {
@ -373,10 +445,11 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
produce<DeviceState>((draft) => { produce<DeviceState>((draft) => {
const device = draft.devices.get(id); const device = draft.devices.get(id);
if (!device) { if (!device) {
return return;
} }
device.nodesMap.delete(nodeNum); device.nodesMap.delete(nodeNum);
})) }),
);
}, },
setDialogOpen: (dialog: DialogVariant, open: boolean) => { setDialogOpen: (dialog: DialogVariant, open: boolean) => {
set( set(
@ -463,7 +536,7 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
if (!device) return; if (!device) return;
const currentCount = device.unreadCounts.get(nodeNum) ?? 0; const currentCount = device.unreadCounts.get(nodeNum) ?? 0;
device.unreadCounts.set(nodeNum, currentCount + 1); device.unreadCounts.set(nodeNum, currentCount + 1);
}) }),
); );
}, },
resetUnread: (nodeNum: number) => { resetUnread: (nodeNum: number) => {
@ -475,16 +548,19 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
if (device.unreadCounts.get(nodeNum) === 0) { if (device.unreadCounts.get(nodeNum) === 0) {
device.unreadCounts.delete(nodeNum); device.unreadCounts.delete(nodeNum);
} }
}) }),
); );
}, },
getNodes: (filter?: (node: Protobuf.Mesh.NodeInfo) => boolean): Protobuf.Mesh.NodeInfo[] => { getNodes: (
filter?: (node: Protobuf.Mesh.NodeInfo) => boolean,
): Protobuf.Mesh.NodeInfo[] => {
const device = get().devices.get(id); const device = get().devices.get(id);
if (!device) { if (!device) {
return []; return [];
} }
const allNodes = Array.from(device.nodesMap.values()).filter( const allNodes = Array.from(device.nodesMap.values()).filter(
(node) => node.num !== get().devices.get(id)?.hardware.myNodeNum); (node) => node.num !== get().devices.get(id)?.hardware.myNodeNum,
);
if (filter) { if (filter) {
return allNodes.filter(filter); return allNodes.filter(filter);
} }
@ -505,7 +581,7 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
if (!device) { if (!device) {
return 0; return 0;
} }
return device.nodesMap.size return device.nodesMap.size;
}, },
}); });
}), }),

91
src/core/stores/messageStore/index.ts

@ -1,9 +1,19 @@
import { create } from 'zustand'; import { create } from "zustand";
import { persist } from 'zustand/middleware'; // import { persist } from "zustand/middleware";
import { produce } from 'immer'; import { produce } from "immer";
import { Types } from '@meshtastic/core'; import { Types } from "@meshtastic/core";
import { storageWithMapSupport } from "../storage/indexDB.ts"; // import { storageWithMapSupport } from "../storage/indexDB.ts";
import { ChannelId, ClearMessageParams, ConversationId, GetMessagesParams, Message, MessageId, MessageLogMap, NodeNum, SetMessageStateParams } from "@core/stores/messageStore/types.ts"; import {
ChannelId,
ClearMessageParams,
ConversationId,
GetMessagesParams,
Message,
MessageId,
MessageLogMap,
NodeNum,
SetMessageStateParams,
} from "@core/stores/messageStore/types.ts";
export enum MessageState { export enum MessageState {
Ack = "ack", Ack = "ack",
@ -16,8 +26,11 @@ export enum MessageType {
Broadcast = "broadcast", Broadcast = "broadcast",
} }
export function getConversationId(node1: NodeNum, node2: NodeNum): ConversationId { export function getConversationId(
return [node1, node2].sort((a, b) => a - b).join(':'); node1: NodeNum,
node2: NodeNum,
): ConversationId {
return [node1, node2].sort((a, b) => a - b).join(":");
} }
export interface MessageStore { export interface MessageStore {
@ -25,9 +38,9 @@ export interface MessageStore {
direct: Map<ConversationId, MessageLogMap>; direct: Map<ConversationId, MessageLogMap>;
broadcast: Map<ChannelId, MessageLogMap>; broadcast: Map<ChannelId, MessageLogMap>;
}; };
}; }
export interface MessageStore { export interface MessageStore {
messages: MessageStore['messages']; messages: MessageStore["messages"];
draft: Map<Types.Destination, string>; draft: Map<Types.Destination, string>;
nodeNum: number; // This device's node number nodeNum: number; // This device's node number
activeChat: number; // Represents otherNodeNum for Direct, or channel for Broadcast activeChat: number; // Represents otherNodeNum for Direct, or channel for Broadcast
@ -47,7 +60,7 @@ export interface MessageStore {
clearDraft: (key: Types.Destination) => void; clearDraft: (key: Types.Destination) => void;
} }
const CURRENT_STORE_VERSION = 0; // const CURRENT_STORE_VERSION = 0;
export const useMessageStore = create<MessageStore>()( export const useMessageStore = create<MessageStore>()(
// persist( // persist(
@ -82,17 +95,29 @@ export const useMessageStore = create<MessageStore>()(
if (message.type === MessageType.Direct) { if (message.type === MessageType.Direct) {
const conversationId = getConversationId(message.from, message.to); const conversationId = getConversationId(message.from, message.to);
if (!state.messages.direct.has(conversationId)) { if (!state.messages.direct.has(conversationId)) {
state.messages.direct.set(conversationId, new Map<MessageId, Message>()); state.messages.direct.set(
conversationId,
new Map<MessageId, Message>(),
);
} }
state.messages.direct.get(conversationId)!.set(message.messageId, message); state.messages.direct.get(conversationId)!.set(
message.messageId,
message,
);
} else if (message.type === MessageType.Broadcast) { } else if (message.type === MessageType.Broadcast) {
const channelId = message.channel as ChannelId; const channelId = message.channel as ChannelId;
if (!state.messages.broadcast.has(channelId)) { if (!state.messages.broadcast.has(channelId)) {
state.messages.broadcast.set(channelId, new Map<MessageId, Message>()); state.messages.broadcast.set(
channelId,
new Map<MessageId, Message>(),
);
} }
state.messages.broadcast.get(channelId)!.set(message.messageId, message); state.messages.broadcast.get(channelId)!.set(
message.messageId,
message,
);
} }
}) }),
); );
}, },
@ -103,7 +128,10 @@ export const useMessageStore = create<MessageStore>()(
let targetMessage: Message | undefined; let targetMessage: Message | undefined;
if (params.type === MessageType.Direct) { if (params.type === MessageType.Direct) {
const conversationId = getConversationId(params.nodeA, params.nodeB); const conversationId = getConversationId(
params.nodeA,
params.nodeB,
);
messageLog = state.messages.direct.get(conversationId); messageLog = state.messages.direct.get(conversationId);
if (messageLog) { if (messageLog) {
targetMessage = messageLog.get(params.messageId); targetMessage = messageLog.get(params.messageId);
@ -118,9 +146,13 @@ export const useMessageStore = create<MessageStore>()(
if (targetMessage) { if (targetMessage) {
targetMessage.state = params.newState ?? MessageState.Ack; targetMessage.state = params.newState ?? MessageState.Ack;
} else { } else {
console.warn(`Message or conversation/channel not found for state update. Params: ${JSON.stringify(params)}`); console.warn(
`Message or conversation/channel not found for state update. Params: ${
JSON.stringify(params)
}`,
);
} }
}) }),
); );
}, },
getMessages: (params: GetMessagesParams): Message[] => { getMessages: (params: GetMessagesParams): Message[] => {
@ -130,7 +162,6 @@ export const useMessageStore = create<MessageStore>()(
if (params.type === MessageType.Direct) { if (params.type === MessageType.Direct) {
const conversationId = getConversationId(params.nodeA, params.nodeB); const conversationId = getConversationId(params.nodeA, params.nodeB);
messageMap = state.messages.direct.get(conversationId); messageMap = state.messages.direct.get(conversationId);
} else { } else {
messageMap = state.messages.broadcast.get(params.channelId); messageMap = state.messages.broadcast.get(params.channelId);
} }
@ -165,23 +196,29 @@ export const useMessageStore = create<MessageStore>()(
const deleted = messageLog.delete(params.messageId); const deleted = messageLog.delete(params.messageId);
if (deleted) { if (deleted) {
console.log(`Deleted message ${params.messageId} from ${params.type} message ${parentKey}`); console.log(
`Deleted message ${params.messageId} from ${params.type} message ${parentKey}`,
);
// Clean up empty MessageLogMap and its entry in the parent map // Clean up empty MessageLogMap and its entry in the parent map
if (messageLog.size === 0) { if (messageLog.size === 0) {
parentMap.delete(parentKey); parentMap.delete(parentKey);
console.log(`Cleaned up empty message entry for ${parentKey}`); console.log(`Cleaned up empty message entry for ${parentKey}`);
} }
} else { } else {
console.warn(`Message ${params.messageId} not found in ${params.type} chat ${parentKey} for deletion.`); console.warn(
`Message ${params.messageId} not found in ${params.type} chat ${parentKey} for deletion.`,
);
} }
} else { } else {
console.warn(`Message entry ${parentKey} not found for message deletion.`); console.warn(
`Message entry ${parentKey} not found for message deletion.`,
);
} }
}) }),
); );
}, },
getDraft: (key) => { getDraft: (key) => {
return get().draft.get(key) ?? ''; return get().draft.get(key) ?? "";
}, },
setDraft: (key, message) => { setDraft: (key, message) => {
set(produce((state: MessageStore) => { set(produce((state: MessageStore) => {
@ -198,7 +235,7 @@ export const useMessageStore = create<MessageStore>()(
state.messages.direct = new Map<ConversationId, MessageLogMap>(); state.messages.direct = new Map<ConversationId, MessageLogMap>();
state.messages.broadcast = new Map<ChannelId, MessageLogMap>(); state.messages.broadcast = new Map<ChannelId, MessageLogMap>();
})); }));
} },
}), }),
// { // {
// name: 'meshtastic-message-store', // name: 'meshtastic-message-store',
@ -209,4 +246,4 @@ export const useMessageStore = create<MessageStore>()(
// nodeNum: state.nodeNum, // nodeNum: state.nodeNum,
// }), // }),
// }) // })
) );

308
src/core/stores/messageStore/messageStore.test.ts

@ -1,25 +1,30 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from "vitest";
import { import {
useMessageStore,
MessageType,
MessageState,
getConversationId, getConversationId,
} from './index.ts'; MessageState,
import type { ConversationId, ChannelId, MessageLogMap, Message } from './types.ts'; MessageType,
import { Types } from '@meshtastic/core'; useMessageStore,
} from "./index.ts";
vi.mock('../storage/indexDB.ts', () => { import type {
let memoryStorage: Record<string, string> = {}; ChannelId,
ConversationId,
Message,
MessageLogMap,
} from "./types.ts";
import { Types } from "@meshtastic/core";
vi.mock("../storage/indexDB.ts", () => {
const memoryStorage: Record<string, string> = {};
return { return {
storageWithMapSupport: { storageWithMapSupport: {
getItem: vi.fn(async (name: string): Promise<string | null> => { getItem: vi.fn(async (name: string): Promise<string | null> => {
return memoryStorage[name] ?? null; return await memoryStorage[name] ?? null;
}), }),
setItem: vi.fn(async (name: string, value: string): Promise<void> => { setItem: vi.fn(async (name: string, value: string): Promise<void> => {
memoryStorage[name] = value; memoryStorage[name] = await value;
}), }),
removeItem: vi.fn(async (name: string): Promise<void> => { removeItem: vi.fn(async (name: string): Promise<void> => {
delete memoryStorage[name]; await delete memoryStorage[name];
}), }),
}, },
}; };
@ -38,7 +43,7 @@ const directMessageToOther1: Message = {
date: Date.now(), date: Date.now(),
messageId: 101, messageId: 101,
state: MessageState.Waiting, state: MessageState.Waiting,
message: 'Hello other 1 from me', message: "Hello other 1 from me",
}; };
const directMessageFromOther1: Message = { const directMessageFromOther1: Message = {
@ -49,7 +54,7 @@ const directMessageFromOther1: Message = {
date: Date.now() + 1000, date: Date.now() + 1000,
messageId: 102, messageId: 102,
state: MessageState.Waiting, state: MessageState.Waiting,
message: 'Hello me from other 1', message: "Hello me from other 1",
}; };
const directMessageToOther2: Message = { const directMessageToOther2: Message = {
@ -60,7 +65,7 @@ const directMessageToOther2: Message = {
date: Date.now() + 2000, date: Date.now() + 2000,
messageId: 103, messageId: 103,
state: MessageState.Waiting, state: MessageState.Waiting,
message: 'Hello other 2 from me', message: "Hello other 2 from me",
}; };
const broadcastMessage1: Message = { const broadcastMessage1: Message = {
@ -71,7 +76,7 @@ const broadcastMessage1: Message = {
date: Date.now() + 3000, date: Date.now() + 3000,
messageId: 201, messageId: 201,
state: MessageState.Waiting, state: MessageState.Waiting,
message: 'Broadcast message 1', message: "Broadcast message 1",
}; };
const broadcastMessage2: Message = { const broadcastMessage2: Message = {
@ -82,10 +87,10 @@ const broadcastMessage2: Message = {
date: Date.now() + 4000, date: Date.now() + 4000,
messageId: 202, messageId: 202,
state: MessageState.Waiting, state: MessageState.Waiting,
message: 'Broadcast message 2', message: "Broadcast message 2",
}; };
describe('useMessageStore', () => { describe("useMessageStore", () => {
const initialState = useMessageStore.getState(); const initialState = useMessageStore.getState();
beforeEach(() => { beforeEach(() => {
@ -97,10 +102,9 @@ describe('useMessageStore', () => {
}, },
draft: new Map<Types.Destination, string>(), draft: new Map<Types.Destination, string>(),
}, true); }, true);
}); });
it('should have correct initial state', () => { it("should have correct initial state", () => {
const state = useMessageStore.getState(); const state = useMessageStore.getState();
expect(state.messages.direct).toBeInstanceOf(Map); expect(state.messages.direct).toBeInstanceOf(Map);
expect(state.messages.direct.size).toBe(0); expect(state.messages.direct.size).toBe(0);
@ -113,23 +117,26 @@ describe('useMessageStore', () => {
expect(state.chatType).toBe(MessageType.Broadcast); expect(state.chatType).toBe(MessageType.Broadcast);
}); });
it('should set nodeNum', () => { it("should set nodeNum", () => {
useMessageStore.getState().setNodeNum(myNodeNum); useMessageStore.getState().setNodeNum(myNodeNum);
expect(useMessageStore.getState().nodeNum).toBe(myNodeNum); expect(useMessageStore.getState().nodeNum).toBe(myNodeNum);
}); });
it('should set activeChat and chatType', () => { it("should set activeChat and chatType", () => {
useMessageStore.getState().setActiveChat(otherNodeNum1); useMessageStore.getState().setActiveChat(otherNodeNum1);
useMessageStore.getState().setChatType(MessageType.Direct); useMessageStore.getState().setChatType(MessageType.Direct);
expect(useMessageStore.getState().activeChat).toBe(otherNodeNum1); expect(useMessageStore.getState().activeChat).toBe(otherNodeNum1);
expect(useMessageStore.getState().chatType).toBe(MessageType.Direct); expect(useMessageStore.getState().chatType).toBe(MessageType.Direct);
}); });
describe('saveMessage', () => { describe("saveMessage", () => {
it('should save a direct message with correct Map structure', () => { it("should save a direct message with correct Map structure", () => {
useMessageStore.getState().saveMessage(directMessageToOther1); useMessageStore.getState().saveMessage(directMessageToOther1);
const state = useMessageStore.getState(); const state = useMessageStore.getState();
const conversationId = getConversationId(directMessageToOther1.from, directMessageToOther1.to); const conversationId = getConversationId(
directMessageToOther1.from,
directMessageToOther1.to,
);
// Check if the conversation Map exists // Check if the conversation Map exists
expect(state.messages.direct.has(conversationId)).toBe(true); expect(state.messages.direct.has(conversationId)).toBe(true);
@ -139,10 +146,12 @@ describe('useMessageStore', () => {
// Check if the message exists within the inner Map // Check if the message exists within the inner Map
expect(conversationLog?.has(directMessageToOther1.messageId)).toBe(true); expect(conversationLog?.has(directMessageToOther1.messageId)).toBe(true);
// Check the message content // Check the message content
expect(conversationLog?.get(directMessageToOther1.messageId)).toEqual(directMessageToOther1); expect(conversationLog?.get(directMessageToOther1.messageId)).toEqual(
directMessageToOther1,
);
}); });
it('should save a broadcast message with correct Map structure', () => { it("should save a broadcast message with correct Map structure", () => {
useMessageStore.getState().saveMessage(broadcastMessage1); useMessageStore.getState().saveMessage(broadcastMessage1);
const state = useMessageStore.getState(); const state = useMessageStore.getState();
const channelId = broadcastMessage1.channel; const channelId = broadcastMessage1.channel;
@ -151,10 +160,12 @@ describe('useMessageStore', () => {
const channelLog = state.messages.broadcast.get(channelId); const channelLog = state.messages.broadcast.get(channelId);
expect(channelLog).toBeInstanceOf(Map); expect(channelLog).toBeInstanceOf(Map);
expect(channelLog?.has(broadcastMessage1.messageId)).toBe(true); expect(channelLog?.has(broadcastMessage1.messageId)).toBe(true);
expect(channelLog?.get(broadcastMessage1.messageId)).toEqual(broadcastMessage1); expect(channelLog?.get(broadcastMessage1.messageId)).toEqual(
broadcastMessage1,
);
}); });
it('should save multiple messages correctly', () => { it("should save multiple messages correctly", () => {
useMessageStore.getState().saveMessage(directMessageToOther1); useMessageStore.getState().saveMessage(directMessageToOther1);
useMessageStore.getState().saveMessage(directMessageFromOther1); useMessageStore.getState().saveMessage(directMessageFromOther1);
useMessageStore.getState().saveMessage(broadcastMessage1); useMessageStore.getState().saveMessage(broadcastMessage1);
@ -162,16 +173,28 @@ describe('useMessageStore', () => {
const state = useMessageStore.getState(); const state = useMessageStore.getState();
const convId1 = getConversationId(myNodeNum, otherNodeNum1); const convId1 = getConversationId(myNodeNum, otherNodeNum1);
expect(state.messages.direct.get(convId1)?.get(directMessageToOther1.messageId)).toEqual(directMessageToOther1); expect(
state.messages.direct.get(convId1)?.get(
expect(state.messages.direct.get(convId1)?.get(directMessageFromOther1.messageId)).toEqual(directMessageFromOther1); directMessageToOther1.messageId,
),
).toEqual(directMessageToOther1);
expect(
state.messages.direct.get(convId1)?.get(
directMessageFromOther1.messageId,
),
).toEqual(directMessageFromOther1);
const channelId = broadcastMessage1.channel; const channelId = broadcastMessage1.channel;
expect(state.messages.broadcast.get(channelId)?.get(broadcastMessage1.messageId)).toEqual(broadcastMessage1); expect(
state.messages.broadcast.get(channelId)?.get(
broadcastMessage1.messageId,
),
).toEqual(broadcastMessage1);
}); });
}); });
describe('getMessages', () => { describe("getMessages", () => {
beforeEach(() => { beforeEach(() => {
useMessageStore.getState().setNodeNum(myNodeNum); useMessageStore.getState().setNodeNum(myNodeNum);
useMessageStore.getState().saveMessage(directMessageToOther1); useMessageStore.getState().saveMessage(directMessageToOther1);
@ -181,56 +204,56 @@ describe('useMessageStore', () => {
useMessageStore.getState().saveMessage(broadcastMessage2); useMessageStore.getState().saveMessage(broadcastMessage2);
}); });
it('should return broadcast messages for a channel, sorted by date', () => { it("should return broadcast messages for a channel, sorted by date", () => {
const messages = useMessageStore.getState().getMessages({ const messages = useMessageStore.getState().getMessages({
type: MessageType.Broadcast, type: MessageType.Broadcast,
channelId: broadcastChannel channelId: broadcastChannel,
}); });
expect(messages).toHaveLength(2); expect(messages).toHaveLength(2);
expect(messages[0]).toEqual(broadcastMessage1); expect(messages[0]).toEqual(broadcastMessage1);
expect(messages[1]).toEqual(broadcastMessage2); expect(messages[1]).toEqual(broadcastMessage2);
}); });
it('should return empty array for broadcast if channel has no messages', () => { it("should return empty array for broadcast if channel has no messages", () => {
const messages = useMessageStore.getState().getMessages({ const messages = useMessageStore.getState().getMessages({
type: MessageType.Broadcast, type: MessageType.Broadcast,
channelId: Types.ChannelNumber.Channel1 channelId: Types.ChannelNumber.Channel1,
}); });
expect(messages).toEqual([]); expect(messages).toEqual([]);
}); });
it('should return combined direct messages for a specific chat pair, sorted by date', () => { it("should return combined direct messages for a specific chat pair, sorted by date", () => {
const messages = useMessageStore.getState().getMessages({ const messages = useMessageStore.getState().getMessages({
type: MessageType.Direct, type: MessageType.Direct,
nodeA: myNodeNum, nodeA: myNodeNum,
nodeB: otherNodeNum1 nodeB: otherNodeNum1,
}); });
expect(messages).toHaveLength(2); expect(messages).toHaveLength(2);
expect(messages[0]).toEqual(directMessageToOther1); expect(messages[0]).toEqual(directMessageToOther1);
expect(messages[1]).toEqual(directMessageFromOther1); expect(messages[1]).toEqual(directMessageFromOther1);
}); });
it('should return only relevant direct messages for a different chat pair', () => { it("should return only relevant direct messages for a different chat pair", () => {
const messages = useMessageStore.getState().getMessages({ const messages = useMessageStore.getState().getMessages({
type: MessageType.Direct, type: MessageType.Direct,
nodeA: myNodeNum, nodeA: myNodeNum,
nodeB: otherNodeNum2 nodeB: otherNodeNum2,
}); });
expect(messages).toHaveLength(1); expect(messages).toHaveLength(1);
expect(messages[0]).toEqual(directMessageToOther2); expect(messages[0]).toEqual(directMessageToOther2);
}); });
it('should return empty array for direct chat if no messages exist', () => { it("should return empty array for direct chat if no messages exist", () => {
const messages = useMessageStore.getState().getMessages({ const messages = useMessageStore.getState().getMessages({
type: MessageType.Direct, type: MessageType.Direct,
nodeA: myNodeNum, nodeA: myNodeNum,
nodeB: 999 nodeB: 999,
}); });
expect(messages).toEqual([]); expect(messages).toEqual([]);
}); });
}); });
describe('setMessageState', () => { describe("setMessageState", () => {
beforeEach(() => { beforeEach(() => {
useMessageStore.getState().setNodeNum(myNodeNum); useMessageStore.getState().setNodeNum(myNodeNum);
useMessageStore.getState().saveMessage(directMessageToOther1); useMessageStore.getState().saveMessage(directMessageToOther1);
@ -238,7 +261,7 @@ describe('useMessageStore', () => {
useMessageStore.getState().saveMessage(broadcastMessage1); useMessageStore.getState().saveMessage(broadcastMessage1);
}); });
it('should update state for a direct message', () => { it("should update state for a direct message", () => {
useMessageStore.getState().setMessageState({ useMessageStore.getState().setMessageState({
type: MessageType.Direct, type: MessageType.Direct,
nodeA: directMessageToOther1.from, nodeA: directMessageToOther1.from,
@ -246,12 +269,17 @@ describe('useMessageStore', () => {
messageId: directMessageToOther1.messageId, messageId: directMessageToOther1.messageId,
newState: MessageState.Ack, newState: MessageState.Ack,
}); });
const conversationId = getConversationId(directMessageToOther1.from, directMessageToOther1.to); const conversationId = getConversationId(
const message = useMessageStore.getState().messages.direct.get(conversationId)?.get(directMessageToOther1.messageId); directMessageToOther1.from,
directMessageToOther1.to,
);
const message = useMessageStore.getState().messages.direct.get(
conversationId,
)?.get(directMessageToOther1.messageId);
expect(message?.state).toBe(MessageState.Ack); expect(message?.state).toBe(MessageState.Ack);
}); });
it('should update state for another direct message in the same conversation', () => { it("should update state for another direct message in the same conversation", () => {
useMessageStore.getState().setMessageState({ useMessageStore.getState().setMessageState({
type: MessageType.Direct, type: MessageType.Direct,
nodeA: directMessageFromOther1.from, nodeA: directMessageFromOther1.from,
@ -259,24 +287,31 @@ describe('useMessageStore', () => {
messageId: directMessageFromOther1.messageId, messageId: directMessageFromOther1.messageId,
newState: MessageState.Failed, newState: MessageState.Failed,
}); });
const conversationId = getConversationId(directMessageFromOther1.from, directMessageFromOther1.to); const conversationId = getConversationId(
const message = useMessageStore.getState().messages.direct.get(conversationId)?.get(directMessageFromOther1.messageId); directMessageFromOther1.from,
directMessageFromOther1.to,
);
const message = useMessageStore.getState().messages.direct.get(
conversationId,
)?.get(directMessageFromOther1.messageId);
expect(message?.state).toBe(MessageState.Failed); expect(message?.state).toBe(MessageState.Failed);
}); });
it('should update state for a broadcast message', () => { it("should update state for a broadcast message", () => {
useMessageStore.getState().setMessageState({ useMessageStore.getState().setMessageState({
type: MessageType.Broadcast, type: MessageType.Broadcast,
channelId: broadcastChannel, channelId: broadcastChannel,
messageId: broadcastMessage1.messageId, messageId: broadcastMessage1.messageId,
newState: MessageState.Ack, newState: MessageState.Ack,
}); });
const message = useMessageStore.getState().messages.broadcast.get(broadcastChannel)?.get(broadcastMessage1.messageId); const message = useMessageStore.getState().messages.broadcast.get(
broadcastChannel,
)?.get(broadcastMessage1.messageId);
expect(message?.state).toBe(MessageState.Ack); expect(message?.state).toBe(MessageState.Ack);
}); });
it('should warn if message is not found (direct)', () => { it("should warn if message is not found (direct)", () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { }); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
useMessageStore.getState().setMessageState({ useMessageStore.getState().setMessageState({
type: MessageType.Direct, type: MessageType.Direct,
nodeA: myNodeNum, nodeA: myNodeNum,
@ -284,24 +319,32 @@ describe('useMessageStore', () => {
messageId: 999, messageId: 999,
newState: MessageState.Ack, newState: MessageState.Ack,
}); });
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Message or conversation/channel not found for state update')); expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining(
"Message or conversation/channel not found for state update",
),
);
warnSpy.mockRestore(); warnSpy.mockRestore();
}); });
it('should warn if message is not found (broadcast)', () => { it("should warn if message is not found (broadcast)", () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { }); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
useMessageStore.getState().setMessageState({ useMessageStore.getState().setMessageState({
type: MessageType.Broadcast, type: MessageType.Broadcast,
channelId: broadcastChannel, channelId: broadcastChannel,
messageId: 999, messageId: 999,
newState: MessageState.Ack, newState: MessageState.Ack,
}); });
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Message or conversation/channel not found for state update')); expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining(
"Message or conversation/channel not found for state update",
),
);
warnSpy.mockRestore(); warnSpy.mockRestore();
}); });
it('should warn if conversation/channel is not found', () => { it("should warn if conversation/channel is not found", () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { }); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
useMessageStore.getState().setMessageState({ useMessageStore.getState().setMessageState({
type: MessageType.Direct, type: MessageType.Direct,
nodeA: myNodeNum, nodeA: myNodeNum,
@ -309,22 +352,30 @@ describe('useMessageStore', () => {
messageId: 101, messageId: 101,
newState: MessageState.Ack, newState: MessageState.Ack,
}); });
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Message or conversation/channel not found for state update')); expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining(
"Message or conversation/channel not found for state update",
),
);
warnSpy.mockRestore(); warnSpy.mockRestore();
}); });
}); });
describe('clearMessageByMessageId', () => { describe("clearMessageByMessageId", () => {
const extraDirectMessageId = 1011; const extraDirectMessageId = 1011;
beforeEach(() => { beforeEach(() => {
useMessageStore.getState().setNodeNum(myNodeNum); useMessageStore.getState().setNodeNum(myNodeNum);
useMessageStore.getState().saveMessage(directMessageToOther1); useMessageStore.getState().saveMessage(directMessageToOther1);
useMessageStore.getState().saveMessage(directMessageFromOther1); useMessageStore.getState().saveMessage(directMessageFromOther1);
useMessageStore.getState().saveMessage(broadcastMessage1); useMessageStore.getState().saveMessage(broadcastMessage1);
useMessageStore.getState().saveMessage({ ...directMessageToOther1, messageId: extraDirectMessageId, date: Date.now() + 50 }); useMessageStore.getState().saveMessage({
...directMessageToOther1,
messageId: extraDirectMessageId,
date: Date.now() + 50,
});
}); });
it('should delete a specific direct message', () => { it("should delete a specific direct message", () => {
const messageIdToDelete = directMessageToOther1.messageId; const messageIdToDelete = directMessageToOther1.messageId;
const nodeA = directMessageToOther1.from; const nodeA = directMessageToOther1.from;
const nodeB = directMessageToOther1.to; const nodeB = directMessageToOther1.to;
@ -334,19 +385,20 @@ describe('useMessageStore', () => {
type: MessageType.Direct, type: MessageType.Direct,
nodeA: nodeA, nodeA: nodeA,
nodeB: nodeB, nodeB: nodeB,
messageId: messageIdToDelete messageId: messageIdToDelete,
}); });
const state = useMessageStore.getState(); const state = useMessageStore.getState();
const conversationLog = state.messages.direct.get(conversationId); const conversationLog = state.messages.direct.get(conversationId);
expect(conversationLog?.has(messageIdToDelete)).toBe(false); expect(conversationLog?.has(messageIdToDelete)).toBe(false);
expect(conversationLog?.has(extraDirectMessageId)).toBe(true); expect(conversationLog?.has(extraDirectMessageId)).toBe(true);
expect(conversationLog?.has(directMessageFromOther1.messageId)).toBe(true); expect(conversationLog?.has(directMessageFromOther1.messageId)).toBe(
true,
);
expect(state.messages.direct.has(conversationId)).toBe(true); expect(state.messages.direct.has(conversationId)).toBe(true);
}); });
it('should delete another specific direct message', () => { it("should delete another specific direct message", () => {
const messageIdToDelete = directMessageFromOther1.messageId; const messageIdToDelete = directMessageFromOther1.messageId;
const nodeA = directMessageFromOther1.from; const nodeA = directMessageFromOther1.from;
const nodeB = directMessageFromOther1.to; const nodeB = directMessageFromOther1.to;
@ -356,7 +408,7 @@ describe('useMessageStore', () => {
type: MessageType.Direct, type: MessageType.Direct,
nodeA: nodeA, nodeA: nodeA,
nodeB: nodeB, nodeB: nodeB,
messageId: messageIdToDelete messageId: messageIdToDelete,
}); });
const state = useMessageStore.getState(); const state = useMessageStore.getState();
@ -366,38 +418,63 @@ describe('useMessageStore', () => {
expect(conversationLog?.has(extraDirectMessageId)).toBe(true); expect(conversationLog?.has(extraDirectMessageId)).toBe(true);
}); });
it("should delete a specific broadcast message", () => {
it('should delete a specific broadcast message', () => {
const messageIdToDelete = broadcastMessage1.messageId; const messageIdToDelete = broadcastMessage1.messageId;
const channelId = broadcastMessage1.channel; const channelId = broadcastMessage1.channel;
useMessageStore.getState().clearMessageByMessageId({ useMessageStore.getState().clearMessageByMessageId({
type: MessageType.Broadcast, type: MessageType.Broadcast,
channelId: channelId, channelId: channelId,
messageId: messageIdToDelete messageId: messageIdToDelete,
}); });
const state = useMessageStore.getState(); const state = useMessageStore.getState();
expect(state.messages.broadcast.get(channelId)?.get(messageIdToDelete)).toBeUndefined(); expect(state.messages.broadcast.get(channelId)?.get(messageIdToDelete))
.toBeUndefined();
}); });
it('should clean up empty conversation/channel Maps', () => { it("should clean up empty conversation/channel Maps", () => {
const directConvId = getConversationId(directMessageFromOther1.from, directMessageFromOther1.to); const directConvId = getConversationId(
directMessageFromOther1.from,
directMessageFromOther1.to,
);
const broadcastChanId = broadcastMessage1.channel; const broadcastChanId = broadcastMessage1.channel;
useMessageStore.getState().clearMessageByMessageId({ type: MessageType.Direct, nodeA: directMessageToOther1.from, nodeB: directMessageToOther1.to, messageId: directMessageToOther1.messageId }); useMessageStore.getState().clearMessageByMessageId({
useMessageStore.getState().clearMessageByMessageId({ type: MessageType.Direct, nodeA: directMessageFromOther1.from, nodeB: directMessageFromOther1.to, messageId: directMessageFromOther1.messageId }); type: MessageType.Direct,
useMessageStore.getState().clearMessageByMessageId({ type: MessageType.Direct, nodeA: directMessageToOther1.from, nodeB: directMessageToOther1.to, messageId: extraDirectMessageId }); nodeA: directMessageToOther1.from,
nodeB: directMessageToOther1.to,
messageId: directMessageToOther1.messageId,
});
useMessageStore.getState().clearMessageByMessageId({
type: MessageType.Direct,
nodeA: directMessageFromOther1.from,
nodeB: directMessageFromOther1.to,
messageId: directMessageFromOther1.messageId,
});
useMessageStore.getState().clearMessageByMessageId({
type: MessageType.Direct,
nodeA: directMessageToOther1.from,
nodeB: directMessageToOther1.to,
messageId: extraDirectMessageId,
});
expect(useMessageStore.getState().messages.direct.has(directConvId)).toBe(false); expect(useMessageStore.getState().messages.direct.has(directConvId)).toBe(
false,
);
useMessageStore.getState().clearMessageByMessageId({ type: MessageType.Broadcast, channelId: broadcastChanId, messageId: broadcastMessage1.messageId }); useMessageStore.getState().clearMessageByMessageId({
type: MessageType.Broadcast,
channelId: broadcastChanId,
messageId: broadcastMessage1.messageId,
});
expect(useMessageStore.getState().messages.broadcast.has(broadcastChanId)).toBe(false); expect(useMessageStore.getState().messages.broadcast.has(broadcastChanId))
.toBe(false);
}); });
it('should not error when trying to delete non-existent message', () => { it("should not error when trying to delete non-existent message", () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { }); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const conversationId = getConversationId(myNodeNum, otherNodeNum1); const conversationId = getConversationId(myNodeNum, otherNodeNum1);
expect(() => { expect(() => {
@ -405,30 +482,34 @@ describe('useMessageStore', () => {
type: MessageType.Direct, type: MessageType.Direct,
nodeA: myNodeNum, nodeA: myNodeNum,
nodeB: otherNodeNum1, nodeB: otherNodeNum1,
messageId: 9999 messageId: 9999,
}); });
}).not.toThrow(); }).not.toThrow();
const state = useMessageStore.getState(); const state = useMessageStore.getState();
const conversationLog = state.messages.direct.get(conversationId); const conversationLog = state.messages.direct.get(conversationId);
expect(conversationLog?.size).toBe(3); // 101, 102, 1011 expect(conversationLog?.size).toBe(3); // 101, 102, 1011
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('not found in direct chat')); expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("not found in direct chat"),
);
warnSpy.mockRestore(); warnSpy.mockRestore();
}); });
it('should not error when trying to delete from non-existent conversation/channel', () => { it("should not error when trying to delete from non-existent conversation/channel", () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { }); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(() => { expect(() => {
useMessageStore.getState().clearMessageByMessageId({ useMessageStore.getState().clearMessageByMessageId({
type: MessageType.Direct, type: MessageType.Direct,
nodeA: myNodeNum, nodeA: myNodeNum,
nodeB: 9998, nodeB: 9998,
messageId: 101 messageId: 101,
}); });
}).not.toThrow(); }).not.toThrow();
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Message entry")); expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("Message entry"),
);
expect(warnSpy).toHaveBeenCalledTimes(1); expect(warnSpy).toHaveBeenCalledTimes(1);
@ -436,43 +517,54 @@ describe('useMessageStore', () => {
}); });
}); });
describe('Drafts', () => { describe("Drafts", () => {
const draftKeyDirect = otherNodeNum1; const draftKeyDirect = otherNodeNum1;
const draftKeyBroadcast = broadcastChannel; const draftKeyBroadcast = broadcastChannel;
const draftMessage = 'This is a draft'; const draftMessage = "This is a draft";
it('should set and get a draft for direct chat', () => { it("should set and get a draft for direct chat", () => {
useMessageStore.getState().setDraft(draftKeyDirect, draftMessage); useMessageStore.getState().setDraft(draftKeyDirect, draftMessage);
expect(useMessageStore.getState().draft.get(draftKeyDirect)).toBe(draftMessage); expect(useMessageStore.getState().draft.get(draftKeyDirect)).toBe(
expect(useMessageStore.getState().getDraft(draftKeyDirect)).toBe(draftMessage); draftMessage,
);
expect(useMessageStore.getState().getDraft(draftKeyDirect)).toBe(
draftMessage,
);
}); });
it('should set and get a draft for broadcast chat', () => { it("should set and get a draft for broadcast chat", () => {
useMessageStore.getState().setDraft(draftKeyBroadcast, draftMessage); useMessageStore.getState().setDraft(draftKeyBroadcast, draftMessage);
expect(useMessageStore.getState().draft.get(draftKeyBroadcast)).toBe(draftMessage); expect(useMessageStore.getState().draft.get(draftKeyBroadcast)).toBe(
expect(useMessageStore.getState().getDraft(draftKeyBroadcast)).toBe(draftMessage); draftMessage,
);
expect(useMessageStore.getState().getDraft(draftKeyBroadcast)).toBe(
draftMessage,
);
}); });
it('should return empty string for non-existent draft', () => { it("should return empty string for non-existent draft", () => {
expect(useMessageStore.getState().getDraft(999)).toBe(''); expect(useMessageStore.getState().getDraft(999)).toBe("");
}); });
it('should clear a draft', () => { it("should clear a draft", () => {
useMessageStore.getState().setDraft(draftKeyDirect, draftMessage); useMessageStore.getState().setDraft(draftKeyDirect, draftMessage);
expect(useMessageStore.getState().draft.has(draftKeyDirect)).toBe(true); expect(useMessageStore.getState().draft.has(draftKeyDirect)).toBe(true);
useMessageStore.getState().clearDraft(draftKeyDirect); useMessageStore.getState().clearDraft(draftKeyDirect);
expect(useMessageStore.getState().draft.has(draftKeyDirect)).toBe(false); expect(useMessageStore.getState().draft.has(draftKeyDirect)).toBe(false);
expect(useMessageStore.getState().getDraft(draftKeyDirect)).toBe(''); expect(useMessageStore.getState().getDraft(draftKeyDirect)).toBe("");
}); });
}); });
describe('deleteAllMessages', () => { describe("deleteAllMessages", () => {
it('should clear all direct and broadcast messages, leaving empty Maps', () => { it("should clear all direct and broadcast messages, leaving empty Maps", () => {
useMessageStore.getState().saveMessage(directMessageToOther1); useMessageStore.getState().saveMessage(directMessageToOther1);
useMessageStore.getState().saveMessage(broadcastMessage1); useMessageStore.getState().saveMessage(broadcastMessage1);
expect(useMessageStore.getState().messages.direct.size).toBeGreaterThan(0); expect(useMessageStore.getState().messages.direct.size).toBeGreaterThan(
expect(useMessageStore.getState().messages.broadcast.size).toBeGreaterThan(0); 0,
);
expect(useMessageStore.getState().messages.broadcast.size)
.toBeGreaterThan(0);
useMessageStore.getState().deleteAllMessages(); useMessageStore.getState().deleteAllMessages();
@ -483,4 +575,4 @@ describe('useMessageStore', () => {
expect(state.messages.broadcast.size).toBe(0); expect(state.messages.broadcast.size).toBe(0);
}); });
}); });
}); });

20
src/core/stores/messageStore/types.ts

@ -21,14 +21,14 @@ interface GenericMessage<T extends MessageType> extends MessageBase {
type: T; type: T;
} }
type Message = GenericMessage<MessageType.Direct> | GenericMessage<MessageType.Broadcast>; type Message =
| GenericMessage<MessageType.Direct>
| GenericMessage<MessageType.Broadcast>;
type GetMessagesParams = type GetMessagesParams =
| { type: MessageType.Direct; nodeA: NodeNum; nodeB: NodeNum } | { type: MessageType.Direct; nodeA: NodeNum; nodeB: NodeNum }
| { type: MessageType.Broadcast; channelId: ChannelId }; | { type: MessageType.Broadcast; channelId: ChannelId };
type SetMessageStateParams = type SetMessageStateParams =
| { | {
type: MessageType.Direct; type: MessageType.Direct;
@ -58,13 +58,13 @@ type ClearMessageParams =
}; };
export type { export type {
Message,
ConversationId,
NodeNum,
MessageLogMap,
ChannelId, ChannelId,
MessageId, ClearMessageParams,
ConversationId,
GetMessagesParams, GetMessagesParams,
Message,
MessageId,
MessageLogMap,
NodeNum,
SetMessageStateParams, SetMessageStateParams,
ClearMessageParams, };
}

18
src/core/stores/sidebarStore.tsx

@ -1,4 +1,4 @@
import React, { createContext, useState, useContext, useMemo } from 'react'; import React, { createContext, useContext, useMemo, useState } from "react";
interface SidebarContextProps { interface SidebarContextProps {
isCollapsed: boolean; isCollapsed: boolean;
@ -6,13 +6,17 @@ interface SidebarContextProps {
toggleSidebar: () => void; toggleSidebar: () => void;
} }
const SidebarContext = createContext<SidebarContextProps | undefined>(undefined); const SidebarContext = createContext<SidebarContextProps | undefined>(
undefined,
);
export const SidebarProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { export const SidebarProvider: React.FC<{ children: React.ReactNode }> = (
{ children },
) => {
const [isCollapsed, setIsCollapsed] = useState<boolean>(false); const [isCollapsed, setIsCollapsed] = useState<boolean>(false);
const toggleSidebar = useMemo(() => () => { const toggleSidebar = useMemo(() => () => {
setIsCollapsed(prev => !prev); setIsCollapsed((prev) => !prev);
}, []); }, []);
const value = useMemo(() => ({ const value = useMemo(() => ({
@ -22,7 +26,7 @@ export const SidebarProvider: React.FC<{ children: React.ReactNode }> = ({ child
}), [isCollapsed, toggleSidebar]); }), [isCollapsed, toggleSidebar]);
return ( return (
<SidebarContext.Provider value={value} > <SidebarContext.Provider value={value}>
{children} {children}
</SidebarContext.Provider> </SidebarContext.Provider>
); );
@ -31,7 +35,7 @@ export const SidebarProvider: React.FC<{ children: React.ReactNode }> = ({ child
export const useSidebar = (): SidebarContextProps => { export const useSidebar = (): SidebarContextProps => {
const context = useContext(SidebarContext); const context = useContext(SidebarContext);
if (context === undefined) { if (context === undefined) {
throw new Error('useSidebar must be used within a SidebarProvider'); throw new Error("useSidebar must be used within a SidebarProvider");
} }
return context; return context;
}; };

25
src/core/stores/storage/indexDB.ts

@ -1,5 +1,5 @@
import { PersistStorage, StateStorage, } from "zustand/middleware"; // Added StorageValue for clarity, though not strictly needed in the final signature here import { PersistStorage, StateStorage } from "zustand/middleware";
import { get, set, del } from "idb-keyval"; import { del, get, set } from "idb-keyval";
import { ChannelId, MessageLogMap } from "@core/stores/messageStore/types.ts"; import { ChannelId, MessageLogMap } from "@core/stores/messageStore/types.ts";
type PersistedMessageState = { type PersistedMessageState = {
@ -22,30 +22,31 @@ export const zustandIndexDBStorage: StateStorage = {
}, },
}; };
type SerializedMap<K = unknown, V = unknown> = { type SerializedMap<K = unknown, V = unknown> = {
__dataType: 'Map'; __dataType: "Map";
value: Array<[K, V]>; value: Array<[K, V]>;
}; };
// deno-lint-ignore no-explicit-any
type JsonReplacer = (this: any, key: string, value: unknown) => unknown; type JsonReplacer = (this: any, key: string, value: unknown) => unknown;
const replacer: JsonReplacer = (_, value) => { const replacer: JsonReplacer = (_, value) => {
if (value instanceof Map) { if (value instanceof Map) {
const map = value as Map<unknown, unknown>; const map = value as Map<unknown, unknown>;
const serialized: SerializedMap = { const serialized: SerializedMap = {
__dataType: 'Map', __dataType: "Map",
value: Array.from(map.entries()), value: Array.from(map.entries()),
}; };
return serialized; return serialized;
} }
return value; return value;
}; };
// deno-lint-ignore no-explicit-any
type JsonReviver = (this: any, key: string, value: unknown) => unknown; type JsonReviver = (this: any, key: string, value: unknown) => unknown;
function isSerializedMap(value: unknown): value is SerializedMap { function isSerializedMap(value: unknown): value is SerializedMap {
if (typeof value !== 'object' || value === null || Array.isArray(value)) { if (typeof value !== "object" || value === null || Array.isArray(value)) {
return false; return false;
} }
const potentialMap = value as Partial<SerializedMap>; const potentialMap = value as Partial<SerializedMap>;
return potentialMap.__dataType === 'Map' && Array.isArray(potentialMap.value); return potentialMap.__dataType === "Map" && Array.isArray(potentialMap.value);
} }
const reviver: JsonReviver = (_, value) => { const reviver: JsonReviver = (_, value) => {
if (isSerializedMap(value)) { if (isSerializedMap(value)) {
@ -54,11 +55,10 @@ const reviver: JsonReviver = (_, value) => {
return value; return value;
}; };
export const storageWithMapSupport: PersistStorage<PersistedMessageState> = { export const storageWithMapSupport: PersistStorage<PersistedMessageState> = {
getItem: async (name): Promise<PersistedMessageState | null> => { getItem: async (name): Promise<PersistedMessageState | null> => {
const str = await zustandIndexDBStorage.getItem(name); const str = await zustandIndexDBStorage.getItem(name);
if (!str) { return null; } if (!str) return null;
try { try {
const parsed = JSON.parse(str, reviver) as PersistedMessageState; const parsed = JSON.parse(str, reviver) as PersistedMessageState;
return parsed; return parsed;
@ -72,10 +72,13 @@ export const storageWithMapSupport: PersistStorage<PersistedMessageState> = {
const str = JSON.stringify(newValue, replacer); const str = JSON.stringify(newValue, replacer);
await zustandIndexDBStorage.setItem(name, str); await zustandIndexDBStorage.setItem(name, str);
} catch (error) { } catch (error) {
console.error(`Error stringifying or setting persisted state (${name}):`, error); console.error(
`Error stringifying or setting persisted state (${name}):`,
error,
);
} }
}, },
removeItem: async (name): Promise<void> => { removeItem: async (name): Promise<void> => {
await zustandIndexDBStorage.removeItem(name); await zustandIndexDBStorage.removeItem(name);
}, },
}; };

17
src/core/subscriptions.ts

@ -1,13 +1,13 @@
import type { Device } from "@core/stores/deviceStore.ts"; import type { Device } from "@core/stores/deviceStore.ts";
import { MeshDevice, Protobuf } from "@meshtastic/core"; import { MeshDevice, Protobuf } from "@meshtastic/core";
import { MessageState, MessageType, type MessageStore } from "./stores/messageStore/index.ts"; import { type MessageStore, MessageType } from "./stores/messageStore/index.ts";
import PacketToMessageDTO from "@core/dto/PacketToMessageDTO.ts"; import PacketToMessageDTO from "@core/dto/PacketToMessageDTO.ts";
import NodeInfoFactory from "@core/dto/NodeNumToNodeInfoDTO.ts"; import NodeInfoFactory from "@core/dto/NodeNumToNodeInfoDTO.ts";
export const subscribeAll = ( export const subscribeAll = (
device: Device, device: Device,
connection: MeshDevice, connection: MeshDevice,
messageStore: MessageStore messageStore: MessageStore,
) => { ) => {
let myNodeNum = 0; let myNodeNum = 0;
@ -79,7 +79,6 @@ export const subscribeAll = (
device.setModuleConfig(moduleConfig); device.setModuleConfig(moduleConfig);
}); });
connection.events.onMessagePacket.subscribe((messagePacket) => { connection.events.onMessagePacket.subscribe((messagePacket) => {
// incoming and outgoing messages are handled by this event listener // incoming and outgoing messages are handled by this event listener
console.log("Message Packet", messagePacket); console.log("Message Packet", messagePacket);
@ -117,7 +116,6 @@ export const subscribeAll = (
}); });
}); });
connection.events.onRoutingPacket.subscribe((routingPacket) => { connection.events.onRoutingPacket.subscribe((routingPacket) => {
if (routingPacket.data.variant.case === "errorReason") { if (routingPacket.data.variant.case === "errorReason") {
switch (routingPacket.data.variant.value) { switch (routingPacket.data.variant.value) {
@ -126,19 +124,24 @@ export const subscribeAll = (
break; break;
case Protobuf.Mesh.Routing_Error.NO_CHANNEL: case Protobuf.Mesh.Routing_Error.NO_CHANNEL:
console.error(`Routing Error: ${routingPacket.data.variant.value}`); console.error(`Routing Error: ${routingPacket.data.variant.value}`);
device.setNodeError(routingPacket.from, Protobuf.Mesh.Routing_Error[routingPacket?.data?.variant?.value]); device.setNodeError(
routingPacket.from,
Protobuf.Mesh.Routing_Error[routingPacket?.data?.variant?.value],
);
device.setDialogOpen("refreshKeys", true); device.setDialogOpen("refreshKeys", true);
break; break;
case Protobuf.Mesh.Routing_Error.PKI_UNKNOWN_PUBKEY: case Protobuf.Mesh.Routing_Error.PKI_UNKNOWN_PUBKEY:
console.error(`Routing Error: ${routingPacket.data.variant.value}`); console.error(`Routing Error: ${routingPacket.data.variant.value}`);
device.setNodeError(routingPacket.from, Protobuf.Mesh.Routing_Error[routingPacket?.data?.variant?.value]); device.setNodeError(
routingPacket.from,
Protobuf.Mesh.Routing_Error[routingPacket?.data?.variant?.value],
);
device.setDialogOpen("refreshKeys", true); device.setDialogOpen("refreshKeys", true);
break; break;
default: { default: {
break; break;
} }
} }
} }
}); });
}; };

4
src/core/utils/eventBus.test.ts

@ -1,10 +1,10 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { eventBus } from "@core/utils/eventBus.ts"; import { eventBus } from "@core/utils/eventBus.ts";
describe("EventBus", () => { describe("EventBus", () => {
beforeEach(() => { beforeEach(() => {
// Reset event listeners before each test // Reset event listeners before each test
(eventBus as any).listeners = {}; eventBus.listeners = {};
}); });
it("should register an event listener and trigger it on emit", () => { it("should register an event listener and trigger it on emit", () => {

18
src/core/utils/eventBus.ts

@ -1,8 +1,7 @@
export type EventMap = { export type EventMap = {
'dialog:unsafeRoles': { "dialog:unsafeRoles": {
action: 'confirm' | 'dismiss'; action: "confirm" | "dismiss";
}; };
// add more events as required
}; };
export type EventName = keyof EventMap; export type EventName = keyof EventMap;
@ -11,12 +10,15 @@ export type EventCallback<T extends EventName> = (data: EventMap[T]) => void;
class EventBus { class EventBus {
private listeners: { [K in EventName]?: Array<EventCallback<K>> } = {}; private listeners: { [K in EventName]?: Array<EventCallback<K>> } = {};
public on<T extends EventName>(event: T, callback: EventCallback<T>): () => void { public on<T extends EventName>(
event: T,
callback: EventCallback<T>,
): () => void {
if (!this.listeners[event]) { if (!this.listeners[event]) {
this.listeners[event] = []; this.listeners[event] = [];
} }
this.listeners[event]?.push(callback as any); this.listeners[event]?.push(callback);
return () => { return () => {
this.off(event, callback); this.off(event, callback);
@ -26,7 +28,7 @@ class EventBus {
public off<T extends EventName>(event: T, callback: EventCallback<T>): void { public off<T extends EventName>(event: T, callback: EventCallback<T>): void {
if (!this.listeners[event]) return; if (!this.listeners[event]) return;
const callbackIndex = this.listeners[event]?.indexOf(callback as any); const callbackIndex = this.listeners[event]?.indexOf(callback);
if (callbackIndex !== undefined && callbackIndex > -1) { if (callbackIndex !== undefined && callbackIndex > -1) {
this.listeners[event]?.splice(callbackIndex, 1); this.listeners[event]?.splice(callbackIndex, 1);
} }
@ -35,10 +37,10 @@ class EventBus {
public emit<T extends EventName>(event: T, data: EventMap[T]): void { public emit<T extends EventName>(event: T, data: EventMap[T]): void {
if (!this.listeners[event]) return; if (!this.listeners[event]) return;
this.listeners[event]?.forEach(callback => { this.listeners[event]?.forEach((callback) => {
callback(data); callback(data);
}); });
} }
} }
export const eventBus = new EventBus(); export const eventBus = new EventBus();

1
src/core/utils/github.ts

@ -63,7 +63,6 @@ export default function newGithubIssueUrl(
function validateOptions(options: GithubIssueUrlOptions): ValidatedOptions { function validateOptions(options: GithubIssueUrlOptions): ValidatedOptions {
const repoUrl = options.repoUrl ?? const repoUrl = options.repoUrl ??
(options.user && options.repo (options.user && options.repo
? `https://github.com/${options.user}/${options.repo}` ? `https://github.com/${options.user}/${options.repo}`
: undefined); : undefined);

5
src/core/utils/ip.ts

@ -1,6 +1,7 @@
export function convertIntToIpAddress(int: number): string { export function convertIntToIpAddress(int: number): string {
return `${int & 0xff}.${(int >> 8) & 0xff}.${(int >> 16) & 0xff}.${(int >> 24) & 0xff return `${int & 0xff}.${(int >> 8) & 0xff}.${(int >> 16) & 0xff}.${
}`; (int >> 24) & 0xff
}`;
} }
export function convertIpAddressToInt(ip: string): number | undefined { export function convertIpAddressToInt(ip: string): number | undefined {

23
src/core/utils/string.ts

@ -35,10 +35,18 @@ export interface LengthValidationResult {
currentLength: number | null; currentLength: number | null;
} }
export function validateMaxByteLength(value: string | null | undefined, maxByteLength: number): LengthValidationResult { export function validateMaxByteLength(
value: string | null | undefined,
maxByteLength: number,
): LengthValidationResult {
// Ensure maxByteLength is valid // Ensure maxByteLength is valid
if (typeof maxByteLength !== 'number' || !Number.isInteger(maxByteLength) || maxByteLength < 0) { if (
console.warn('validateMaxByteLength: maxByteLength must be a non-negative integer.'); typeof maxByteLength !== "number" || !Number.isInteger(maxByteLength) ||
maxByteLength < 0
) {
console.warn(
"validateMaxByteLength: maxByteLength must be a non-negative integer.",
);
return { isValid: false, currentLength: null }; // Cannot validate with invalid limit return { isValid: false, currentLength: null }; // Cannot validate with invalid limit
} }
@ -48,8 +56,10 @@ export function validateMaxByteLength(value: string | null | undefined, maxByteL
} }
// Check for TextEncoder availability // Check for TextEncoder availability
if (typeof TextEncoder === 'undefined') { if (typeof TextEncoder === "undefined") {
console.error('validateMaxByteLength: TextEncoder API is not available in this environment.'); console.error(
"validateMaxByteLength: TextEncoder API is not available in this environment.",
);
return { isValid: false, currentLength: null }; // Cannot determine byte length return { isValid: false, currentLength: null }; // Cannot determine byte length
} }
@ -64,8 +74,7 @@ export function validateMaxByteLength(value: string | null | undefined, maxByteL
return { isValid, currentLength }; return { isValid, currentLength };
} catch (error) { } catch (error) {
// Handle potential errors during encoding // Handle potential errors during encoding
console.error('validateMaxByteLength: Error encoding string:', error); console.error("validateMaxByteLength: Error encoding string:", error);
return { isValid: false, currentLength: null }; // Encoding failed return { isValid: false, currentLength: null }; // Encoding failed
} }
} }

4
src/index.css

@ -89,7 +89,6 @@ body {
} }
@layer base { @layer base {
*, *,
::after, ::after,
::before, ::before,
@ -127,7 +126,6 @@ img {
-webkit-user-drag: none; -webkit-user-drag: none;
} }
@keyframes spin-slower { @keyframes spin-slower {
to { to {
transform: rotate(360deg); transform: rotate(360deg);
@ -136,4 +134,4 @@ img {
.animate-spin-slow { .animate-spin-slow {
animation: spin-slower 2s linear infinite; animation: spin-slower 2s linear infinite;
} }

9
src/pages/Channels.tsx

@ -17,8 +17,8 @@ export const getChannelName = (channel: Protobuf.Channel.Channel) =>
channel.settings?.name.length channel.settings?.name.length
? channel.settings?.name ? channel.settings?.name
: channel.index === 0 : channel.index === 0
? "Primary" ? "Primary"
: `Ch ${channel.index}`; : `Ch ${channel.index}`;
const ChannelsPage = () => { const ChannelsPage = () => {
const { channels, setDialogOpen } = useDevice(); const { channels, setDialogOpen } = useDevice();
@ -33,8 +33,9 @@ const ChannelsPage = () => {
<> <>
<PageLayout <PageLayout
leftBar={<Sidebar />} leftBar={<Sidebar />}
label={`Channel: ${currentChannel ? getChannelName(currentChannel) : "Loading..." label={`Channel: ${
}`} currentChannel ? getChannelName(currentChannel) : "Loading..."
}`}
actions={[ actions={[
{ {
key: "search", key: "search",

129
src/pages/Messages.test.tsx

@ -1,74 +1,81 @@
import { describe, it, vi, expect } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { fireEvent, render, screen } from "@testing-library/react";
import { MessagesPage } from "./Messages.tsx"; import { MessagesPage } from "./Messages.tsx";
import { useDevice } from "../core/stores/deviceStore"; import { useDevice } from "../core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core"; import { Protobuf } from "@meshtastic/core";
vi.mock("../core/stores/deviceStore", () => ({ vi.mock("../core/stores/deviceStore", () => ({
useDevice: vi.fn() useDevice: vi.fn(),
})); }));
const mockUseDevice = { const mockUseDevice = {
channels: new Map([ channels: new Map([
[0, { [0, {
index: 0, index: 0,
settings: { name: "Primary" }, settings: { name: "Primary" },
role: Protobuf.Channel.Channel_Role.PRIMARY role: Protobuf.Channel.Channel_Role.PRIMARY,
}] }],
]), ]),
nodes: new Map([ nodes: new Map([
[0, { [0, {
num: 0, num: 0,
user: { longName: "Test Node 0", shortName: "TN0", publicKey: "0000" } user: { longName: "Test Node 0", shortName: "TN0", publicKey: "0000" },
}], }],
[1111, { [1111, {
num: 1111, num: 1111,
user: { longName: "Test Node 1", shortName: "TN1", publicKey: "12345" } user: { longName: "Test Node 1", shortName: "TN1", publicKey: "12345" },
}], }],
[2222, { [2222, {
num: 2222, num: 2222,
user: { longName: "Test Node 2", shortName: "TN2", publicKey: "67890" } user: { longName: "Test Node 2", shortName: "TN2", publicKey: "67890" },
}], }],
[3333, { [3333, {
num: 3333, num: 3333,
user: { longName: "Test Node 3", shortName: "TN3", publicKey: "11111" } user: { longName: "Test Node 3", shortName: "TN3", publicKey: "11111" },
}] }],
]), ]),
hardware: { myNodeNum: 1 }, hardware: { myNodeNum: 1 },
messages: { broadcast: new Map(), direct: new Map() }, messages: { broadcast: new Map(), direct: new Map() },
metadata: new Map(), metadata: new Map(),
unreadCounts: new Map([[1111, 3], [2222, 10]]), unreadCounts: new Map([[1111, 3], [2222, 10]]),
resetUnread: vi.fn(), resetUnread: vi.fn(),
hasNodeError: vi.fn() hasNodeError: vi.fn(),
}; };
describe.skip("Messages Page", () => { describe.skip("Messages Page", () => {
beforeEach(() => { beforeEach(() => {
vi.mocked(useDevice).mockReturnValue(mockUseDevice); vi.mocked(useDevice).mockReturnValue(mockUseDevice);
}); });
it("sorts unreads to the top", () => { it("sorts unreads to the top", () => {
render(<MessagesPage />); render(<MessagesPage />);
const buttonOrder = screen.getAllByRole("button").filter(b => b.textContent.includes("Test Node")); const buttonOrder = screen.getAllByRole("button").filter((b) =>
expect(buttonOrder[0].textContent).toContain("TN2Test Node 210"); b.textContent.includes("Test Node")
expect(buttonOrder[1].textContent).toContain("TN1Test Node 13"); );
expect(buttonOrder[2].textContent).toContain("TN0Test Node 0"); expect(buttonOrder[0].textContent).toContain("TN2Test Node 210");
expect(buttonOrder[3].textContent).toContain("TN3Test Node 3"); expect(buttonOrder[1].textContent).toContain("TN1Test Node 13");
}); expect(buttonOrder[2].textContent).toContain("TN0Test Node 0");
expect(buttonOrder[3].textContent).toContain("TN3Test Node 3");
});
it("updates unread when active chat changes", () => { it("updates unread when active chat changes", () => {
render(<MessagesPage />); render(<MessagesPage />);
const nodeButton = screen.getAllByRole("button").filter(b => b.textContent.includes("TN1Test Node 13"))[0]; const nodeButton =
fireEvent.click(nodeButton); screen.getAllByRole("button").filter((b) =>
expect(mockUseDevice.resetUnread).toHaveBeenCalledWith(1111, 0); b.textContent.includes("TN1Test Node 13")
}); )[0];
fireEvent.click(nodeButton);
expect(mockUseDevice.resetUnread).toHaveBeenCalledWith(1111, 0);
});
it("does not update the incorrect node", async () => { it("does not update the incorrect node", () => {
render(<MessagesPage />); render(<MessagesPage />);
const nodeButton = screen.getAllByRole("button").filter(b => b.textContent.includes("TN1Test Node 1"))[0]; const nodeButton =
fireEvent.click(nodeButton); screen.getAllByRole("button").filter((b) =>
expect(mockUseDevice.resetUnread).toHaveBeenCalledWith(1111, 0); b.textContent.includes("TN1Test Node 1")
expect(mockUseDevice.unreadCounts.get(2222)).toBe(10); )[0];
}); fireEvent.click(nodeButton);
}); expect(mockUseDevice.resetUnread).toHaveBeenCalledWith(1111, 0);
expect(mockUseDevice.unreadCounts.get(2222)).toBe(10);
});
});

240
src/pages/Messages.tsx

@ -12,34 +12,55 @@ import { HashIcon, LockIcon, LockOpenIcon } from "lucide-react";
import { useCallback, useDeferredValue, useMemo, useState } from "react"; import { useCallback, useDeferredValue, useMemo, useState } from "react";
import { MessageInput } from "@components/PageComponents/Messages/MessageInput.tsx"; import { MessageInput } from "@components/PageComponents/Messages/MessageInput.tsx";
import { cn } from "@core/utils/cn.ts"; import { cn } from "@core/utils/cn.ts";
import { MessageState, MessageType, useMessageStore } from "@core/stores/messageStore/index.ts"; import {
MessageState,
MessageType,
useMessageStore,
} from "@core/stores/messageStore/index.ts";
import { useSidebar } from "@core/stores/sidebarStore.tsx"; import { useSidebar } from "@core/stores/sidebarStore.tsx";
import { Input } from "@components/UI/Input.tsx"; import { Input } from "@components/UI/Input.tsx";
type NodeInfoWithUnread = Protobuf.Mesh.NodeInfo & { unreadCount: number }; type NodeInfoWithUnread = Protobuf.Mesh.NodeInfo & { unreadCount: number };
export const MessagesPage = () => { export const MessagesPage = () => {
const { channels, getNodes, getNode, hasNodeError, unreadCounts, resetUnread, connection } = useDevice(); const {
const { getMyNodeNum, getMessages, setActiveChat, chatType, activeChat, setChatType, setMessageState, } = useMessageStore() channels,
getNodes,
getNode,
hasNodeError,
unreadCounts,
resetUnread,
connection,
} = useDevice();
const {
getMyNodeNum,
getMessages,
setActiveChat,
chatType,
activeChat,
setChatType,
setMessageState,
} = useMessageStore();
const { toast } = useToast(); const { toast } = useToast();
const { isCollapsed } = useSidebar() const { isCollapsed } = useSidebar();
const [searchTerm, setSearchTerm] = useState<string>(""); const [searchTerm, setSearchTerm] = useState<string>("");
const deferredSearch = useDeferredValue(searchTerm); const deferredSearch = useDeferredValue(searchTerm);
const filteredNodes = (): NodeInfoWithUnread[] => { const filteredNodes = (): NodeInfoWithUnread[] => {
const lowerCaseSearchTerm = deferredSearch.toLowerCase(); const lowerCaseSearchTerm = deferredSearch.toLowerCase();
return getNodes(node => { return getNodes((node) => {
const longName = node.user?.longName?.toLowerCase() ?? ''; const longName = node.user?.longName?.toLowerCase() ?? "";
const shortName = node.user?.shortName?.toLowerCase() ?? ''; const shortName = node.user?.shortName?.toLowerCase() ?? "";
return longName.includes(lowerCaseSearchTerm) || shortName.includes(lowerCaseSearchTerm) return longName.includes(lowerCaseSearchTerm) ||
shortName.includes(lowerCaseSearchTerm);
}) })
.map((node) => ({ .map((node) => ({
...node, ...node,
unreadCount: unreadCounts.get(node.num) ?? 0, unreadCount: unreadCounts.get(node.num) ?? 0,
})) }))
.sort((a, b) => b.unreadCount - a.unreadCount); .sort((a, b) => b.unreadCount - a.unreadCount);
} };
const allChannels = Array.from(channels.values()); const allChannels = Array.from(channels.values());
const filteredChannels = allChannels.filter( const filteredChannels = allChannels.filter(
@ -55,19 +76,35 @@ export const MessagesPage = () => {
const isDirect = chatType === MessageType.Direct; const isDirect = chatType === MessageType.Direct;
const toValue = isDirect ? activeChat : MessageType.Broadcast; const toValue = isDirect ? activeChat : MessageType.Broadcast;
const channelValue = isDirect ? Types.ChannelNumber.Primary : activeChat ?? 0; const channelValue = isDirect
? Types.ChannelNumber.Primary
console.log(`Sending message: "${message}" to: ${toValue}, channel: ${channelValue}, type: ${chatType}`); : activeChat ?? 0;
let messageId: number | undefined; let messageId: number | undefined;
try { try {
messageId = await connection?.sendText(message, toValue, true, channelValue); messageId = await connection?.sendText(
message,
toValue,
true,
channelValue,
);
if (messageId !== undefined) { if (messageId !== undefined) {
if (chatType === MessageType.Broadcast) { if (chatType === MessageType.Broadcast) {
setMessageState({ type: chatType, channelId: channelValue, messageId, newState: MessageState.Ack }); setMessageState({
type: chatType,
channelId: channelValue,
messageId,
newState: MessageState.Ack,
});
} else { } else {
setMessageState({ type: chatType, nodeA: getMyNodeNum(), nodeB: activeChat, messageId, newState: MessageState.Ack }); setMessageState({
type: chatType,
nodeA: getMyNodeNum(),
nodeB: activeChat,
messageId,
newState: MessageState.Ack,
});
} }
} else { } else {
console.warn("sendText completed but messageId is undefined"); console.warn("sendText completed but messageId is undefined");
@ -78,10 +115,21 @@ export const MessagesPage = () => {
// Note: messageId might be undefined here if the error occurred before it was assigned // Note: messageId might be undefined here if the error occurred before it was assigned
if (chatType === MessageType.Broadcast) { if (chatType === MessageType.Broadcast) {
const failedId = messageId ?? `failed-${Date.now()}`; const failedId = messageId ?? `failed-${Date.now()}`;
setMessageState({ type: chatType, channelId: channelValue, messageId: failedId, newState: MessageState.Failed }); setMessageState({
type: chatType,
channelId: channelValue,
messageId: failedId,
newState: MessageState.Failed,
});
} else { // MessageType.Direct } else { // MessageType.Direct
const failedId = messageId ?? `failed-${Date.now()}`; const failedId = messageId ?? `failed-${Date.now()}`;
setMessageState({ type: chatType, nodeA: getMyNodeNum(), nodeB: activeChat, messageId: failedId, newState: MessageState.Failed }); setMessageState({
type: chatType,
nodeA: getMyNodeNum(),
nodeB: activeChat,
messageId: failedId,
newState: MessageState.Failed,
});
} }
} }
}, [activeChat, chatType, connection, getMyNodeNum, setMessageState]); }, [activeChat, chatType, connection, getMyNodeNum, setMessageState]);
@ -100,7 +148,11 @@ export const MessagesPage = () => {
case MessageType.Direct: case MessageType.Direct:
return ( return (
<ChannelChat <ChannelChat
messages={getMessages({ type: MessageType.Direct, nodeA: getMyNodeNum(), nodeB: activeChat })} messages={getMessages({
type: MessageType.Direct,
nodeA: getMyNodeNum(),
nodeB: activeChat,
})}
/> />
); );
default: default:
@ -110,7 +162,7 @@ export const MessagesPage = () => {
</div> </div>
); );
} }
} };
const leftSidebar = useMemo(() => ( const leftSidebar = useMemo(() => (
<Sidebar> <Sidebar>
@ -119,72 +171,106 @@ export const MessagesPage = () => {
<SidebarButton <SidebarButton
key={channel.index} key={channel.index}
count={unreadCounts.get(channel.index)} count={unreadCounts.get(channel.index)}
label={channel.settings?.name || (channel.index === 0 ? "Primary" : `Ch ${channel.index}`)} label={channel.settings?.name ||
active={activeChat === channel.index && chatType === MessageType.Broadcast} (channel.index === 0 ? "Primary" : `Ch ${channel.index}`)}
active={activeChat === channel.index &&
chatType === MessageType.Broadcast}
onClick={() => { onClick={() => {
setChatType(MessageType.Broadcast); setChatType(MessageType.Broadcast);
setActiveChat(channel.index); setActiveChat(channel.index);
resetUnread(channel.index); resetUnread(channel.index);
}} }}
> >
<HashIcon size={16} className={cn(isCollapsed ? "mr-0 mt-2" : "mr-2")} /> <HashIcon
size={16}
className={cn(isCollapsed ? "mr-0 mt-2" : "mr-2")}
/>
</SidebarButton> </SidebarButton>
))} ))}
</SidebarSection> </SidebarSection>
</Sidebar> </Sidebar>
), [filteredChannels, unreadCounts, activeChat, chatType, isCollapsed, setActiveChat, setChatType, resetUnread]); ), [
filteredChannels,
unreadCounts,
activeChat,
chatType,
isCollapsed,
setActiveChat,
setChatType,
resetUnread,
]);
const rightSidebar = useMemo(() => ( const rightSidebar = useMemo(
<SidebarSection label="" className="px-0 flex flex-col h-full overflow-y-auto"> () => (
<label className="p-2 block"> <SidebarSection
<Input label=""
type="text" className="px-0 flex flex-col h-full overflow-y-auto"
placeholder="Search nodes..." >
value={searchTerm} <label className="p-2 block">
onChange={(e) => setSearchTerm(e.target.value)} <Input
showClearButton={!!searchTerm} type="text"
/> placeholder="Search nodes..."
</label> value={searchTerm}
<div className={cn( onChange={(e) => setSearchTerm(e.target.value)}
"flex flex-col h-full flex-1 overflow-y-auto gap-2.5 pt-1 ", showClearButton={!!searchTerm}
)}> />
{filteredNodes()?.map((node) => ( </label>
<SidebarButton <div
key={node.num} className={cn(
preventCollapse={true} "flex flex-col h-full flex-1 overflow-y-auto gap-2.5 pt-1 ",
label={node.user?.longName ?? `UNK`} )}
count={node.unreadCount > 0 ? node.unreadCount : undefined} >
active={activeChat === node.num && chatType === MessageType.Direct} {filteredNodes()?.map((node) => (
onClick={() => { <SidebarButton
setChatType(MessageType.Direct); key={node.num}
setActiveChat(node.num); preventCollapse
resetUnread(node.num); label={node.user?.longName ?? `UNK`}
}}> count={node.unreadCount > 0 ? node.unreadCount : undefined}
<Avatar active={activeChat === node.num &&
text={node.user?.shortName ?? "UNK"} chatType === MessageType.Direct}
className={cn(hasNodeError(node.num) && "text-red-500")} onClick={() => {
showError={hasNodeError(node.num)} setChatType(MessageType.Direct);
size="sm" setActiveChat(node.num);
/> resetUnread(node.num);
</SidebarButton> }}
))} >
</div> <Avatar
</SidebarSection> text={node.user?.shortName ?? "UNK"}
), [filteredNodes, searchTerm, activeChat, chatType, setActiveChat, setChatType, resetUnread, hasNodeError]); className={cn(hasNodeError(node.num) && "text-red-500")}
showError={hasNodeError(node.num)}
size="sm"
/>
</SidebarButton>
))}
</div>
</SidebarSection>
),
[
filteredNodes,
searchTerm,
activeChat,
chatType,
setActiveChat,
setChatType,
resetUnread,
hasNodeError,
],
);
return ( return (
<PageLayout <PageLayout
label={`Messages: ${isBroadcast && currentChannel label={`Messages: ${
? getChannelName(currentChannel) isBroadcast && currentChannel
: isDirect && otherNode ? getChannelName(currentChannel)
: isDirect && otherNode
? (otherNode.user?.longName ?? "Unknown") ? (otherNode.user?.longName ?? "Unknown")
: "Select a Chat" : "Select a Chat"
}`} }`}
rightBar={rightSidebar} rightBar={rightSidebar}
leftBar={leftSidebar} leftBar={leftSidebar}
actions={isDirect && otherNode actions={isDirect && otherNode
? [ ? [
{ {
key: 'encryption', key: "encryption",
icon: otherNode.user?.publicKey?.length ? LockIcon : LockOpenIcon, icon: otherNode.user?.publicKey?.length ? LockIcon : LockOpenIcon,
iconClasses: otherNode.user?.publicKey?.length iconClasses: otherNode.user?.publicKey?.length
? "text-green-600" ? "text-green-600"
@ -204,19 +290,23 @@ export const MessagesPage = () => {
{renderChatContent()} {renderChatContent()}
<div className="flex-none dark:bg-slate-900 p-2"> <div className="flex-none dark:bg-slate-900 p-2">
{(isBroadcast || isDirect) ? ( {(isBroadcast || isDirect)
<MessageInput ? (
to={isDirect ? activeChat : MessageType.Broadcast} <MessageInput
onSend={sendText} to={isDirect ? activeChat : MessageType.Broadcast}
maxBytes={200} onSend={sendText}
/> maxBytes={200}
) : ( />
<div className="p-4 text-center text-slate-400 italic">Select a chat to send a message.</div> )
)} : (
<div className="p-4 text-center text-slate-400 italic">
Select a chat to send a message.
</div>
)}
</div> </div>
</div> </div>
</PageLayout> </PageLayout>
); );
}; };
export default MessagesPage; export default MessagesPage;

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save