Browse Source

Revert "Merge Master into Stable"

pull/595/head
Dan Ditomaso 1 year ago
committed by GitHub
parent
commit
a99e2d4bd3
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      .github/workflows/ci.yml
  2. 8
      .github/workflows/release.yml
  3. 6
      deno.lock
  4. 2
      package.json
  5. 16
      public/Logo.svg
  6. 19
      src/App.tsx
  7. 86
      src/components/BatteryStatus.tsx
  8. 12
      src/components/CommandPalette/index.tsx
  9. 76
      src/components/DeviceSelector.tsx
  10. 25
      src/components/DeviceSelectorButton.tsx
  11. 69
      src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.test.tsx
  12. 63
      src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx
  13. 90
      src/components/Dialog/DeviceNameDialog.tsx
  14. 8
      src/components/Dialog/DialogManager.tsx
  15. 1
      src/components/Dialog/ImportDialog.tsx
  16. 4
      src/components/Dialog/LocationResponseDialog.tsx
  17. 14
      src/components/Dialog/NewDeviceDialog.tsx
  18. 99
      src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.test.tsx
  19. 6
      src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx
  20. 16
      src/components/Dialog/NodeOptionsDialog.tsx
  21. 16
      src/components/Dialog/PkiRegenerateDialog.tsx
  22. 2
      src/components/Dialog/QRDialog.tsx
  23. 126
      src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.test.tsx
  24. 24
      src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx
  25. 41
      src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.test.ts
  26. 17
      src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.ts
  27. 4
      src/components/Dialog/RemoveNodeDialog.tsx
  28. 1
      src/components/Dialog/ShutdownDialog.tsx
  29. 10
      src/components/Dialog/TracerouteResponseDialog.tsx
  30. 4
      src/components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.test.tsx
  31. 31
      src/components/Form/FormInput.tsx
  32. 22
      src/components/Form/FormPasswordGenerator.tsx
  33. 19
      src/components/Form/FormWrapper.tsx
  34. 12
      src/components/PageComponents/Channel.tsx
  35. 2
      src/components/PageComponents/Config/Bluetooth.tsx
  36. 4
      src/components/PageComponents/Config/Device/Device.test.tsx
  37. 6
      src/components/PageComponents/Config/Display.tsx
  38. 2
      src/components/PageComponents/Config/Position.tsx
  39. 19
      src/components/PageComponents/Config/Security/Security.tsx
  40. 20
      src/components/PageComponents/Connect/BLE.tsx
  41. 80
      src/components/PageComponents/Connect/HTTP.tsx
  42. 16
      src/components/PageComponents/Connect/Serial.tsx
  43. 46
      src/components/PageComponents/Map/NodeDetail.tsx
  44. 96
      src/components/PageComponents/Messages/ChannelChat.tsx
  45. 175
      src/components/PageComponents/Messages/Message.tsx
  46. 91
      src/components/PageComponents/Messages/MessageActionsMenu.tsx
  47. 278
      src/components/PageComponents/Messages/MessageInput.test.tsx
  48. 106
      src/components/PageComponents/Messages/MessageInput.tsx
  49. 180
      src/components/PageComponents/Messages/MessageItem.tsx
  50. 47
      src/components/PageComponents/Messages/TraceRoute.test.tsx
  51. 8
      src/components/PageComponents/Messages/TraceRoute.tsx
  52. 135
      src/components/PageLayout.tsx
  53. 295
      src/components/Sidebar.tsx
  54. 12
      src/components/ThemeSwitcher.tsx
  55. 71
      src/components/UI/Avatar.tsx
  56. 46
      src/components/UI/Button.tsx
  57. 9
      src/components/UI/Checkbox/index.tsx
  58. 6
      src/components/UI/Command.tsx
  59. 9
      src/components/UI/Dialog.tsx
  60. 2
      src/components/UI/Footer.tsx
  61. 37
      src/components/UI/Generator.tsx
  62. 192
      src/components/UI/Input.tsx
  63. 81
      src/components/UI/Sidebar/SidebarButton.tsx
  64. 47
      src/components/UI/Sidebar/SidebarSection.tsx
  65. 79
      src/components/UI/Sidebar/sidebarButton.tsx
  66. 87
      src/components/UI/Slider.tsx
  67. 2
      src/components/UI/Tabs.tsx
  68. 4
      src/components/UI/Toast.tsx
  69. 2
      src/components/UI/Tooltip.tsx
  70. 2
      src/components/UI/Typography/Heading.tsx
  71. 2
      src/components/UI/Typography/Link.tsx
  72. 10
      src/components/generic/Mono.tsx
  73. 63
      src/components/generic/Table/index.tsx
  74. 40
      src/core/dto/NodeNumToNodeInfoDTO.ts
  75. 51
      src/core/dto/PacketToMessageDTO.ts
  76. 51
      src/core/hooks/useCopyToClipboard.ts
  77. 294
      src/core/hooks/useNodeFilters.ts
  78. 66
      src/core/hooks/usePasswordVisibilityToggle.test.ts
  79. 20
      src/core/hooks/usePasswordVisibilityToggle.ts
  80. 16
      src/core/stores/appStore.ts
  81. 484
      src/core/stores/deviceStore.ts
  82. 212
      src/core/stores/messageStore/index.ts
  83. 486
      src/core/stores/messageStore/messageStore.test.ts
  84. 70
      src/core/stores/messageStore/types.ts
  85. 37
      src/core/stores/sidebarStore.tsx
  86. 81
      src/core/stores/storage/indexDB.ts
  87. 40
      src/core/subscriptions.ts
  88. 40
      src/core/utils/string.ts
  89. 1
      src/index.css
  90. 13
      src/pages/Channels.tsx
  91. 2
      src/pages/Config/DeviceConfig.tsx
  92. 21
      src/pages/Config/index.tsx
  93. 2
      src/pages/Dashboard/index.tsx
  94. 265
      src/pages/Map/FilterControl.tsx
  95. 46
      src/pages/Map/index.tsx
  96. 74
      src/pages/Messages.test.tsx
  97. 322
      src/pages/Messages.tsx
  98. 69
      src/pages/Nodes.tsx
  99. 18
      src/tests/setupTests.ts
  100. 4
      vite.config.ts

1
.github/workflows/ci.yml

@ -3,7 +3,6 @@ name: CI
on:
push:
branches:
- main
- master
permissions:

8
.github/workflows/release.yml

@ -1,4 +1,4 @@
name: Release
name: 'Release'
on:
release:
@ -38,12 +38,6 @@ jobs:
name: build
path: dist/build.tar
- name: Attach build.tar to release
run: |
gh release upload ${{ github.event.release.tag_name }} dist/build.tar
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3

6
deno.lock

@ -45,7 +45,6 @@
"npm:crypto-random-string@5": "5.0.0",
"npm:gzipper@^8.2.1": "8.2.1",
"npm:happy-dom@^17.4.4": "17.4.4",
"npm:idb-keyval@^6.2.1": "6.2.1",
"npm:immer@^10.1.1": "10.1.1",
"npm:js-cookie@^3.0.5": "3.0.5",
"npm:[email protected]": "[email protected]",
@ -4493,9 +4492,6 @@
"[email protected]": {
"integrity": "sha512-J+FkSdyD+0mA0N+81tMotaRMfSL9SGi+xpD3T6YApKsc3bGSXJlfXri3VyFOeYkfLRQisDk1W+jIFFKBeUBbBg=="
},
"[email protected]": {
"integrity": "sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg=="
},
"[email protected]": {
"integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ=="
},
@ -6468,7 +6464,6 @@
"npm:@radix-ui/react-scroll-area@^1.2.3",
"npm:@radix-ui/react-select@^2.1.6",
"npm:@radix-ui/react-separator@^1.1.2",
"npm:@radix-ui/react-slider@^1.3.2",
"npm:@radix-ui/react-switch@^1.1.3",
"npm:@radix-ui/react-tabs@^1.1.3",
"npm:@radix-ui/react-toast@^1.2.6",
@ -6496,7 +6491,6 @@
"npm:crypto-random-string@5",
"npm:gzipper@^8.2.1",
"npm:happy-dom@^17.4.4",
"npm:idb-keyval@^6.2.1",
"npm:immer@^10.1.1",
"npm:js-cookie@^3.0.5",
"npm:[email protected]",

2
package.json

@ -51,7 +51,6 @@
"@radix-ui/react-scroll-area": "^1.2.3",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slider": "^1.3.2",
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-toast": "^1.2.6",
@ -63,7 +62,6 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"crypto-random-string": "^5.0.0",
"idb-keyval": "^6.2.1",
"immer": "^10.1.1",
"js-cookie": "^3.0.5",
"lucide-react": "^0.486.0",

16
public/Logo.svg

@ -1,16 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no" ?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="512" height="512" viewBox="0 0 512 512" xml:space="preserve">
<desc>Created with Fabric.js 4.6.0</desc>
<defs>
</defs>
<g transform="matrix(1 0 0 1 256 256)" id="xYQ9Gk9Jwpgj_HMOXB3F_" >
<path style="stroke: rgb(213,130,139); stroke-width: 0; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(103,234,148); fill-rule: nonzero; opacity: 1;" vector-effect="non-scaling-stroke" transform=" translate(-256, -256)" d="M 0 0 L 512 0 L 512 512 L 0 512 z" stroke-linecap="round" />
</g>
<g transform="matrix(1.79 0 0 1.79 313.74 258.36)" id="1xBsk2n9FZp60Rz1O-ceJ" >
<path style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: round; stroke-miterlimit: 2; fill: rgb(44,45,60); fill-rule: evenodd; opacity: 1;" vector-effect="non-scaling-stroke" transform=" translate(-250.97, -362.41)" d="M 250.908 330.267 L 193.126 415.005 L 180.938 406.694 L 244.802 313.037 C 246.174 311.024 248.453 309.819 250.889 309.816 C 253.326 309.814 255.606 311.015 256.982 313.026 L 320.994 406.536 L 308.821 414.869 L 250.908 330.267 Z" stroke-linecap="round" />
</g>
<g transform="matrix(1.81 0 0 1.81 145 256.15)" id="KxN7E9YpbyPgz0S4z4Cl6" >
<path style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: round; stroke-miterlimit: 2; fill: rgb(44,45,60); fill-rule: evenodd; opacity: 1;" vector-effect="non-scaling-stroke" transform=" translate(-115.14, -528.06)" d="M 87.642 581.398 L 154.757 482.977 L 142.638 474.713 L 75.523 573.134 L 87.642 581.398 Z" stroke-linecap="round" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

19
src/App.tsx

@ -1,5 +1,6 @@
import { DeviceWrapper } from "@app/DeviceWrapper.tsx";
import { PageRouter } from "@app/PageRouter.tsx";
import { DeviceSelector } from "@components/DeviceSelector.tsx";
import { DialogManager } from "@components/Dialog/DialogManager.tsx";
import { NewDeviceDialog } from "@components/Dialog/NewDeviceDialog.tsx";
import { KeyBackupReminder } from "@components/KeyBackupReminder.tsx";
@ -13,8 +14,6 @@ import { ErrorBoundary } from "react-error-boundary";
import { ErrorPage } from "@components/UI/ErrorPage.tsx";
import { MapProvider } from "react-map-gl/maplibre";
import { CommandPalette } from "@components/CommandPalette/index.tsx";
import { SidebarProvider } from "@core/stores/sidebarStore.tsx";
import { useTheme } from "@core/hooks/useTheme.ts";
export const App = (): JSX.Element => {
@ -24,9 +23,6 @@ export const App = (): JSX.Element => {
const device = getDevice(selectedDevice);
// Sets up light/dark mode based on user preferences or system settings
useTheme()
return (
<ErrorBoundary FallbackComponent={ErrorPage}>
<NewDeviceDialog
@ -37,11 +33,12 @@ export const App = (): JSX.Element => {
/>
<Toaster />
<DeviceWrapper device={device}>
<div className="flex h-screen flex-col bg-background-primary text-text-primary" style={{ scrollbarWidth: 'thin' }}>
<SidebarProvider>
<div className="h-full flex flex-col">
<div className="flex h-screen flex-col overflow-hidden bg-background-primary text-text-primary">
<div className="flex grow">
<DeviceSelector />
<div className="flex grow flex-col">
{device ? (
<div className="h-full flex w-full">
<div className="flex h-screen w-full">
<DialogManager />
<KeyBackupReminder />
<CommandPalette />
@ -56,9 +53,9 @@ export const App = (): JSX.Element => {
</>
)}
</div>
</SidebarProvider>
</div>
</div>
</DeviceWrapper>
</ErrorBoundary >
</ErrorBoundary>
);
};

86
src/components/BatteryStatus.tsx

@ -1,86 +0,0 @@
import React from 'react';
import {
PlugZapIcon,
BatteryFullIcon,
BatteryMediumIcon,
BatteryLowIcon,
} from 'lucide-react';
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
interface DeviceMetrics {
batteryLevel?: number | null;
voltage?: number | null;
}
interface BatteryStatusProps {
deviceMetrics?: DeviceMetrics | null;
}
interface BatteryStateConfig {
condition: (level: number) => boolean;
Icon: React.ElementType;
className: string;
text: (level: number) => string;
}
const batteryStates: BatteryStateConfig[] = [
{
condition: level => level > 100,
Icon: PlugZapIcon,
className: 'text-gray-500',
text: () => 'Plugged in',
},
{
condition: level => level > 80,
Icon: BatteryFullIcon,
className: 'text-green-500',
text: level => `${level}% charging`,
},
{
condition: level => level > 20,
Icon: BatteryMediumIcon,
className: 'text-yellow-500',
text: level => `${level}% charging`,
},
{
condition: () => true,
Icon: BatteryLowIcon,
className: 'text-red-500',
text: level => `${level}% charging`,
},
];
const getBatteryState = (level: number) => {
return batteryStates.find(state => state.condition(level));
};
const BatteryStatus: React.FC<BatteryStatusProps> = ({ deviceMetrics }) => {
if (deviceMetrics?.batteryLevel === undefined || deviceMetrics?.batteryLevel === null) {
return null;
}
const { batteryLevel, voltage } = deviceMetrics;
const currentState = getBatteryState(batteryLevel) ?? batteryStates[batteryStates.length - 1];
const BatteryIcon = currentState.Icon;
const iconClassName = currentState.className;
const statusText = currentState.text(batteryLevel);
const voltageTitle = `${voltage?.toPrecision(3) ?? 'Unknown'} volts`;
return (
<div
className="flex items-center gap-1 mt-0.5 text-gray-500"
title={voltageTitle}
>
<BatteryIcon size={22} className={iconClassName} />
<Subtle aria-label="Battery">
{statusText}
</Subtle>
</div>
);
};
export default BatteryStatus;

12
src/components/CommandPalette/index.tsx

@ -60,7 +60,7 @@ export const CommandPalette = () => {
setSelectedDevice,
} = useAppStore();
const { getDevices } = useDeviceStore();
const { setDialogOpen, setActivePage, getNode, connection } = useDevice();
const { setDialogOpen, setActivePage, connection } = useDevice();
const { pinnedItems, togglePinnedItem } = usePinnedItems({ storageName: 'pinnedCommandMenuGroups' });
const groups: Group[] = [
@ -115,12 +115,12 @@ export const CommandPalette = () => {
icon: ArrowLeftRightIcon,
subItems: getDevices().map((device) => ({
label:
getNode(device.hardware.myNodeNum)?.user?.longName ??
device.nodes.get(device.hardware.myNodeNum)?.user?.longName ??
device.hardware.myNodeNum.toString(),
icon: (
<Avatar
text={
getNode(device.hardware.myNodeNum)?.user?.shortName ??
device.nodes.get(device.hardware.myNodeNum)?.user?.shortName ??
device.hardware.myNodeNum.toString()
}
/>
@ -219,10 +219,10 @@ export const CommandPalette = () => {
},
},
{
label: "Clear All Stored Message",
label: "[WIP] Clear Messages",
icon: EraserIcon,
action() {
setDialogOpen("deleteMessages", true);
alert("This feature is not implemented");
},
},
],
@ -262,7 +262,7 @@ export const CommandPalette = () => {
type="button"
onClick={() => togglePinnedItem(group.label)}
className={cn(
"transition-all duration-300 scale-100 cursor-pointer p-2 focus:*:data-label:opacity-100"
"transition-all duration-300 scale-100 cursor-pointer m-0.5 p-2 focus:*:data-label:opacity-100"
)}
aria-description={
pinnedItems.includes(group.label)

76
src/components/DeviceSelector.tsx

@ -0,0 +1,76 @@
import { DeviceSelectorButton } from "@components/DeviceSelectorButton.tsx";
import ThemeSwitcher from "@components/ThemeSwitcher.tsx";
import { Separator } from "@components/UI/Seperator.tsx";
import { Code } from "@components/UI/Typography/Code.tsx";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDeviceStore } from "@core/stores/deviceStore.ts";
import { HomeIcon, PlusIcon, SearchIcon } from "lucide-react";
import { Avatar } from "@components/UI/Avatar.tsx";
export const DeviceSelector = () => {
const { getDevices } = useDeviceStore();
const {
selectedDevice,
setSelectedDevice,
setCommandPaletteOpen,
setConnectDialogOpen,
} = useAppStore();
return (
<nav className="flex flex-col justify-between border-r-[0.5px] border-slate-300 pt-2 dark:border-slate-700">
<div className="flex flex-col overflow-y-hidden">
<ul className="flex w-20 grow flex-col items-center space-y-4 bg-transparent py-4 px-5">
<DeviceSelectorButton
active={selectedDevice === 0}
onClick={() => {
setSelectedDevice(0);
}}
>
<HomeIcon />
</DeviceSelectorButton>
{getDevices().map((device) => (
<DeviceSelectorButton
key={device.id}
onClick={() => {
setSelectedDevice(device.id);
}}
active={selectedDevice === device.id}
>
<Avatar
text={device.nodes
.get(device.hardware.myNodeNum)
?.user?.shortName.toString() ?? "UNK"}
/>
</DeviceSelectorButton>
))}
<Separator />
<button
type="button"
onClick={() => setConnectDialogOpen(true)}
className="transition-all duration-300"
>
<PlusIcon />
</button>
</ul>
</div>
<div className="flex w-20 flex-col items-center space-y-5 px-5 pb-5">
<ThemeSwitcher />
<button
type="button"
className="transition-all hover:text-accent"
onClick={() => setCommandPaletteOpen(true)}
>
<SearchIcon />
</button>
{/* TODO: This is being commented out until its fixed */}
{
/* <button type="button" className="transition-all hover:text-accent">
<LanguagesIcon />
</button> */
}
<Separator />
<Code>{import.meta.env.VITE_COMMIT_HASH}</Code>
</div>
</nav>
);
};

25
src/components/DeviceSelectorButton.tsx

@ -0,0 +1,25 @@
export interface DeviceSelectorButtonProps {
active: boolean;
onClick: () => void;
children?: React.ReactNode;
}
export const DeviceSelectorButton = ({
onClick,
children,
}: DeviceSelectorButtonProps) => (
<li
className="aspect-w-1 aspect-h-1 relative w-full"
onClick={onClick}
onKeyDown={onClick}
>
{
/* {active && (
<div className="absolute -left-2 h-10 w-1.5 rounded-full bg-accent" />
)} */
}
<div className="flex aspect-square cursor-pointer flex-col items-center justify-center">
{children}
</div>
</li>
);

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

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

63
src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx

@ -1,63 +0,0 @@
import { Button } from "@components/UI/Button.tsx";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { AlertTriangleIcon } from "lucide-react";
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
export interface DeleteMessagesDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const DeleteMessagesDialog = ({
open,
onOpenChange,
}: DeleteMessagesDialogProps) => {
const { deleteAllMessages } = useMessageStore();
const handleCloseDialog = () => {
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose data-testid="dialog-close-button" />
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangleIcon className="h-5 w-5 text-warning" />
Clear All Messages
</DialogTitle>
<DialogDescription>
This action will clear all message history. This cannot be undone.
Are you sure you want to continue?
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-4">
<Button
variant="outline"
onClick={handleCloseDialog}
>
Dismiss
</Button>
<Button
variant="destructive"
onClick={() => {
deleteAllMessages();
handleCloseDialog();
}}
>
Clear Messages
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

90
src/components/Dialog/DeviceNameDialog.tsx

@ -10,11 +10,10 @@ import {
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { Input } from "@components/UI/Input.tsx";
import { Label } from "@components/UI/Label.tsx";
import { Protobuf } from "@meshtastic/core";
import { useForm } from "react-hook-form";
import { GenericInput } from "@components/Form/FormInput.tsx";
import { validateMaxByteLength } from "@core/utils/string.ts";
export interface User {
longName: string;
@ -25,44 +24,32 @@ export interface DeviceNameDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
const MAX_LONG_NAME_BYTE_LENGTH = 40;
const MAX_SHORT_NAME_BYTE_LENGTH = 4;
export const DeviceNameDialog = ({
open,
onOpenChange,
}: DeviceNameDialogProps) => {
const { hardware, getNode, connection } = useDevice();
const myNode = getNode(hardware.myNodeNum);
const { hardware, nodes, connection } = useDevice();
const defaultValues = {
longName: myNode?.user?.longName ?? "Unknown",
shortName: myNode?.user?.shortName ?? "??",
};
const myNode = nodes.get(hardware.myNodeNum);
const { getValues, setValue, reset, control, handleSubmit } = useForm<User>({
values: defaultValues,
const { register, handleSubmit } = useForm<User>({
values: {
longName: myNode?.user?.longName ?? "Unknown",
shortName: myNode?.user?.shortName ?? "Unknown",
},
});
const { currentLength: currentLongNameLength } = validateMaxByteLength(getValues('longName'), MAX_LONG_NAME_BYTE_LENGTH);
const { currentLength: currentShortNameLength } = validateMaxByteLength(getValues('shortName'), MAX_SHORT_NAME_BYTE_LENGTH);
const onSubmit = handleSubmit((data) => {
connection?.setOwner(
create(Protobuf.Mesh.UserSchema, {
...(myNode?.user ?? {}),
...myNode?.user,
...data,
}),
);
onOpenChange(false);
});
const handleReset = () => {
reset({ longName: "", shortName: "" });
setValue("longName", "");
setValue("shortName", "");
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
@ -73,51 +60,22 @@ export const DeviceNameDialog = ({
The Device will restart once the config is saved.
</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit} className="flex flex-col gap-4">
<div>
<Label htmlFor="longName">Long Name</Label>
<GenericInput
control={control}
field={{
name: "longName",
label: "Long Name",
type: "text",
properties: {
className: 'text-slate-900 dark:text-slate-200',
fieldLength: {
currentValueLength: currentLongNameLength ?? 0,
max: MAX_LONG_NAME_BYTE_LENGTH,
showCharacterCount: true,
},
},
}}
<div className="gap-4">
<form onSubmit={onSubmit}>
<Label>Long Name</Label>
<Input className="dark:text-slte-900" {...register("longName")} />
<Label>Short Name</Label>
<Input
className="dark:text-slte-900"
maxLength={4}
{...register("shortName")}
/>
</div>
<div>
<Label htmlFor="shortName">Short Name</Label>
<GenericInput
control={control}
field={{
name: "shortName",
label: "Short Name",
type: "text",
properties: {
fieldLength: {
currentValueLength: currentShortNameLength ?? 0,
max: MAX_SHORT_NAME_BYTE_LENGTH,
showCharacterCount: true,
},
},
}}
/>
</div>
<DialogFooter>
<Button type="button" variant="destructive" onClick={handleReset}>Reset</Button>
<Button type="submit">Save</Button>
</DialogFooter>
</form>
</form>
</div>
<DialogFooter>
<Button onClick={() => onSubmit()}>Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
};

8
src/components/Dialog/DialogManager.tsx

@ -10,8 +10,6 @@ import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDeta
import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx";
import { RefreshKeysDialog } from "@components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx";
import { RebootOTADialog } from "@components/Dialog/RebootOTADialog.tsx";
import { DeleteMessagesDialog } from "@components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx";
export const DialogManager = () => {
const { channels, config, dialog, setDialogOpen } = useDevice();
@ -86,12 +84,6 @@ export const DialogManager = () => {
setDialogOpen("rebootOTA", open);
}}
/>
<DeleteMessagesDialog
open={dialog.deleteMessages}
onOpenChange={(open) => {
setDialogOpen("deleteMessages", open);
}}
/>
</>
);
};

1
src/components/Dialog/ImportDialog.tsx

@ -109,6 +109,7 @@ export const ImportDialog = ({
<Input
value={importDialogInput}
suffix={validUrl ? "✅" : "❌"}
className="dark:text-slate-900"
onChange={(e) => {
setImportDialogInput(e.target.value);
}}

4
src/components/Dialog/LocationResponseDialog.tsx

@ -21,9 +21,9 @@ export const LocationResponseDialog = ({
open,
onOpenChange,
}: LocationResponseDialogProps) => {
const { getNode } = useDevice();
const { nodes } = useDevice();
const from = getNode(location?.from ?? 0);
const from = nodes.get(location?.from ?? 0);
const longName = from?.user?.longName ??
(from ? `!${numberToHexUnpadded(from?.num)}` : "Unknown");
const shortName = from?.user?.shortName ??

14
src/components/Dialog/NewDeviceDialog.tsx

@ -22,12 +22,9 @@ import { Subtle } from "@components/UI/Typography/Subtle.tsx";
import { AlertCircle } from "lucide-react";
import { Link } from "../UI/Typography/Link.tsx";
import { Fragment } from "react/jsx-runtime";
import { useState } from "react";
export interface TabElementProps {
closeDialog: () => void;
connectionInProgress: boolean;
setConnectionInProgress: (inProgress: boolean) => void;
}
export interface TabManifest {
@ -114,28 +111,25 @@ export const NewDeviceDialog = ({
open,
onOpenChange,
}: NewDeviceProps) => {
const [connectionInProgress, setConnectionInProgress] =
useState(false);
const { unsupported } = useBrowserFeatureDetection();
const tabs: TabManifest[] = [
{
label: "HTTP",
element: HTTP,
isDisabled: connectionInProgress,
isDisabled: false,
},
{
label: "Bluetooth",
element: BLE,
isDisabled: unsupported.includes("Web Bluetooth") ||
unsupported.includes("Secure Context") || connectionInProgress,
unsupported.includes("Secure Context"),
},
{
label: "Serial",
element: Serial,
isDisabled: unsupported.includes("Web Serial") ||
unsupported.includes("Secure Context") || connectionInProgress,
unsupported.includes("Secure Context"),
},
];
@ -160,7 +154,7 @@ export const NewDeviceDialog = ({
{tab.isDisabled
? <ErrorMessage missingFeatures={unsupported} />
: null}
<tab.element closeDialog={() => onOpenChange(false)} setConnectionInProgress={setConnectionInProgress} connectionInProgress={connectionInProgress} />
<tab.element closeDialog={() => onOpenChange(false)} />
</fieldset>
</TabsContent>
))}

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

@ -1,17 +1,12 @@
import { describe, it, vi, expect, beforeEach } from "vitest";
import { describe, it, vi, expect, beforeEach, Mock } from "vitest";
import { render, screen } from "@testing-library/react";
import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useAppStore } from "@core/stores/appStore.ts";
import { Protobuf } from "@meshtastic/core";
vi.mock("@core/stores/deviceStore");
vi.mock("@core/stores/appStore");
const mockUseDevice = vi.mocked(useDevice);
const mockUseAppStore = vi.mocked(useAppStore);
describe("NodeDetailsDialog", () => {
const mockDevice = {
num: 1234,
@ -34,21 +29,17 @@ describe("NodeDetailsDialog", () => {
voltage: 4.2,
uptimeSeconds: 3600,
},
} as unknown as Protobuf.Mesh.NodeInfo;
};
beforeEach(() => {
// Reset mocks before each test
vi.resetAllMocks();
mockUseDevice.mockReturnValue({
getNode: (nodeNum: number) => {
if (nodeNum === 1234) {
return mockDevice;
}
return undefined;
},
(useDevice as Mock).mockReturnValue({
nodes: new Map([[1234, mockDevice]]),
});
mockUseAppStore.mockReturnValue({
(useAppStore as unknown as Mock).mockReturnValue({
nodeNumDetails: 1234,
});
});
@ -56,87 +47,27 @@ describe("NodeDetailsDialog", () => {
it("renders node details correctly", () => {
render(<NodeDetailsDialog open={true} onOpenChange={() => { }} />);
expect(screen.getByText(/Node Details for Test Node \(TN\)/i)).toBeInTheDocument();
expect(screen.getByText(/Node Details for Test Node/i)).toBeInTheDocument();
expect(screen.getByText("Node Number: 1234")).toBeInTheDocument();
expect(screen.getByText(/Node Hex: !/i)).toBeInTheDocument();
expect(screen.getByText(/Last Heard:/i)).toBeInTheDocument();
expect(screen.getByText(/Coordinates:/i)).toBeInTheDocument();
const link = screen.getByRole('link', { name: /^45, -75$/ });
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute('href', expect.stringContaining('openstreetmap.org'));
expect(screen.getByText(/Altitude: 200m/i)).toBeInTheDocument();
expect(screen.getByText(/Air TX utilization: 50.12%/i)).toBeInTheDocument();
expect(screen.getByText(/Channel utilization: 75.46%/i)).toBeInTheDocument();
expect(screen.getByText(/Battery level: 88.79%/i)).toBeInTheDocument();
expect(screen.getByText(/Voltage: 4.20V/i)).toBeInTheDocument();
expect(screen.getByText(/Uptime:/i)).toBeInTheDocument();
expect(screen.getByText(/All Raw Metrics:/i)).toBeInTheDocument();
expect(screen.getByText(/Coordinates:/i)).toBeInTheDocument();
expect(screen.getByText("45, -75")).toBeInTheDocument();
expect(screen.getByText(/Altitude: 200m/i)).toBeInTheDocument();
expect(screen.getByText(/Role:/i)).toBeInTheDocument();
});
it("renders null if device is not found", () => {
const requestedNodeNum = 5678;
mockUseAppStore.mockReturnValue({
nodeNumDetails: requestedNodeNum,
(useDevice as Mock).mockReturnValue({
nodes: new Map(),
});
mockUseDevice.mockReturnValue({
getNode: (nodeNum: number) => {
if (nodeNum === requestedNodeNum) {
return undefined;
}
if (nodeNum === 1234) {
return mockDevice;
}
return undefined;
},
});
const { container } = render(<NodeDetailsDialog open={true} onOpenChange={() => { }} />);
expect(container.firstChild).toBeNull();
expect(screen.queryByText(/Node Details for/i)).not.toBeInTheDocument();
});
it("renders correctly when position is missing", () => {
const nodeWithoutPosition = { ...mockDevice, position: undefined };
mockUseDevice.mockReturnValue({ getNode: () => nodeWithoutPosition });
mockUseAppStore.mockReturnValue({ nodeNumDetails: 1234 });
render(<NodeDetailsDialog open={true} onOpenChange={() => { }} />);
expect(screen.queryByText(/Coordinates:/i)).not.toBeInTheDocument();
expect(screen.queryByText(/Altitude:/i)).not.toBeInTheDocument();
expect(screen.getByText(/Node Details for Test Node/i)).toBeInTheDocument();
});
it("renders correctly when deviceMetrics are missing", () => {
const nodeWithoutMetrics = { ...mockDevice, deviceMetrics: undefined };
mockUseDevice.mockReturnValue({ getNode: () => nodeWithoutMetrics });
mockUseAppStore.mockReturnValue({ nodeNumDetails: 1234 });
render(<NodeDetailsDialog open={true} onOpenChange={() => { }} />);
expect(screen.queryByText(/Device Metrics:/i)).not.toBeInTheDocument();
expect(screen.queryByText(/Air TX utilization:/i)).not.toBeInTheDocument();
expect(screen.getByText(/Node Details for Test Node/i)).toBeInTheDocument();
});
it("renders 'Never' for lastHeard when timestamp is 0", () => {
const nodeNeverHeard = { ...mockDevice, lastHeard: 0 };
mockUseDevice.mockReturnValue({ getNode: () => nodeNeverHeard });
mockUseAppStore.mockReturnValue({ nodeNumDetails: 1234 });
render(<NodeDetailsDialog open={true} onOpenChange={() => { }} />);
expect(screen.getByText(/Last Heard: Never/i)).toBeInTheDocument();
expect(screen.queryByText(/Node Details for/i)).not.toBeInTheDocument();
});
});
});

6
src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx

@ -29,10 +29,10 @@ export const NodeDetailsDialog = ({
open,
onOpenChange,
}: NodeDetailsDialogProps) => {
const { getNode } = useDevice();
const { nodes } = useDevice();
const { nodeNumDetails } = useAppStore();
const device = getNode(nodeNumDetails);
const device = nodes.get(nodeNumDetails);
if (!device) return null;
@ -131,7 +131,7 @@ export const NodeDetailsDialog = ({
{device.deviceMetrics && (
<div className="text-slate-900 dark:text-slate-100 bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
<p className="text-lg font-semibold text-slate-900 dark:text-slate-0">
<p className="text-lg font-semibold text-slate-900 dark:text-slate-50">
Device Metrics:
</p>
{deviceMetricsMap.map(

16
src/components/Dialog/NodeOptionsDialog.tsx

@ -13,7 +13,6 @@ import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { TrashIcon } from "lucide-react";
import { Button } from "../UI/Button.tsx";
import { MessageType, useMessageStore } from "../../core/stores/messageStore/index.ts";
export interface NodeOptionsDialogProps {
node: Protobuf.Mesh.NodeInfo | undefined;
@ -30,23 +29,23 @@ export const NodeOptionsDialog = ({
const {
setNodeNumToBeRemoved,
setNodeNumDetails,
setChatType,
setActiveChat,
} = useAppStore();
const { setChatType, setActiveChat } = useMessageStore();
if (!node) return null;
const longName = node?.user?.longName ??
(node ? `!${numberToHexUnpadded(node?.num)}` : "Unknown");
const shortName = node?.user?.shortName ??
(node ? `${numberToHexUnpadded(node?.num).substring(0, 4)}` : "UNK");
function handleDirectMessage() {
setChatType(MessageType.Direct);
if (!node) return;
setChatType("direct");
setActiveChat(node.num);
setActivePage("messages");
}
function handleRequestPosition() {
if (!node) return;
toast({
title: "Requesting position, please wait...",
});
@ -59,6 +58,7 @@ export const NodeOptionsDialog = ({
}
function handleTraceroute() {
if (!node) return;
toast({
title: "Sending Traceroute, please wait...",
});
@ -92,7 +92,7 @@ export const NodeOptionsDialog = ({
key="remove"
variant="destructive"
onClick={() => {
setNodeNumToBeRemoved(node?.num);
setNodeNumToBeRemoved(node.num);
setDialogOpen("nodeRemoval", true);
}}
>
@ -103,7 +103,7 @@ export const NodeOptionsDialog = ({
<div>
<Button
onClick={() => {
setNodeNumDetails(node?.num);
setNodeNumDetails(node.num);
setDialogOpen("nodeDetails", true);
}}
>

16
src/components/Dialog/PkiRegenerateDialog.tsx

@ -10,22 +10,12 @@ import {
} from "@components/UI/Dialog.tsx";
export interface PkiRegenerateDialogProps {
text: {
title: string;
description: string;
button: string;
}
open: boolean;
onOpenChange: () => void;
onSubmit: () => void;
}
export const PkiRegenerateDialog = ({
text = {
title: "Regenerate Key Pair",
description: "Are you sure you want to regenerate key pair?",
button: "Regenerate",
},
open,
onOpenChange,
onSubmit,
@ -35,14 +25,14 @@ export const PkiRegenerateDialog = ({
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>{text?.title}</DialogTitle>
<DialogTitle>Regenerate Key pair?</DialogTitle>
<DialogDescription>
{text?.description}
Are you sure you want to regenerate key pair?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="destructive" onClick={() => onSubmit()}>
{text?.button}
Regenerate
</Button>
</DialogFooter>
</DialogContent>

2
src/components/Dialog/QRDialog.tsx

@ -133,8 +133,8 @@ export const QRDialog = ({
<Input
value={qrCodeUrl}
disabled
className="dark:text-slate-900"
action={{
key: 'copy-value',
icon: ClipboardIcon,
onClick() {
void navigator.clipboard.writeText(qrCodeUrl);

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

@ -1,97 +1,55 @@
import { render, screen } from "@testing-library/react";
import { DeviceContext, useDeviceStore } from "@core/stores/deviceStore.ts";
import { RefreshKeysDialog } from "./RefreshKeysDialog.tsx";
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
import { render, screen, fireEvent } from "@testing-library/react";
import { beforeEach, describe, expect, it, Mock, vi } from "vitest";
import { RefreshKeysDialog } from "./RefreshKeysDialog";
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
import { expect, test, vi, beforeEach, afterEach } from 'vitest';
import { Protobuf } from "@meshtastic/core";
vi.mock("@core/stores/messageStore");
vi.mock("./useRefreshKeysDialog");
vi.mock("./useRefreshKeysDialog.ts", () => ({
useRefreshKeysDialog: vi.fn(),
}));
const mockUseMessageStore = vi.mocked(useMessageStore);
const mockUseRefreshKeysDialog = vi.mocked(useRefreshKeysDialog);
describe("RefreshKeysDialog Component", () => {
let handleCloseDialogMock: Mock;
let handleNodeRemoveMock: Mock;
let onOpenChangeMock: Mock;
const getInitialState = () => useDeviceStore.getInitialState?.() ?? { devices: new Map(), remoteDevices: new Map() };
beforeEach(() => {
handleCloseDialogMock = vi.fn();
handleNodeRemoveMock = vi.fn();
onOpenChangeMock = vi.fn();
beforeEach(() => {
useDeviceStore.setState(getInitialState(), true);
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
test("renders dialog when there is a node error for the active chat", () => {
const deviceId = 1;
const nodeWithErrorNum = 12345;
const activeChatNum = nodeWithErrorNum;
const deviceStore = useDeviceStore.getState().addDevice(deviceId);
deviceStore.addNodeInfo({
num: nodeWithErrorNum,
user: {
id: nodeWithErrorNum.toString(),
publicKey: new Uint8Array(0),
hwModel: Protobuf.Mesh.HardwareModel.HELTEC_V3,
longName: "Problem Node Long",
shortName: "ProbNode",
isLicensed: false,
macaddr: new Uint8Array(0)
},
lastHeard: Date.now() / 1000,
snr: 10
} as Protobuf.Mesh.NodeInfo);
deviceStore.setNodeError(activeChatNum, "PKI_MISMATCH");
const updatedDeviceState = useDeviceStore.getState().getDevice(deviceId);
if (!updatedDeviceState) {
throw new Error("Failed to get updated device state from store for provider");
}
mockUseMessageStore.mockReturnValue({ activeChat: activeChatNum });
const mockHandleClose = vi.fn();
const mockHandleRemove = vi.fn();
mockUseRefreshKeysDialog.mockReturnValue({
handleCloseDialog: mockHandleClose,
handleNodeRemove: mockHandleRemove,
(useRefreshKeysDialog as Mock).mockReturnValue({
handleCloseDialog: handleCloseDialogMock,
handleNodeRemove: handleNodeRemoveMock,
});
});
render(
<DeviceContext.Provider value={updatedDeviceState}>
<RefreshKeysDialog open onOpenChange={vi.fn()} />
</DeviceContext.Provider>
);
expect(screen.getByText(/Keys Mismatch - Problem Node Long/)).toBeInTheDocument();
expect(screen.getByText(/Your node is unable to send a direct message to node: Problem Node Long \(ProbNode\)/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Request New Keys" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Dismiss" })).toBeInTheDocument();
});
test("does not render dialog if no error exists for active chat", () => {
const deviceId = 1;
const activeChatNum = 54321;
useDeviceStore.getState().addDevice(deviceId);
it("renders the dialog with correct content", () => {
render(<RefreshKeysDialog open={true} onOpenChange={onOpenChangeMock} />);
expect(screen.getByText("Keys Mismatch")).toBeInTheDocument();
expect(screen.getByText("Request New Keys")).toBeInTheDocument();
expect(screen.getByText("Dismiss")).toBeInTheDocument();
});
const currentDeviceState = useDeviceStore.getState().getDevice(deviceId);
if (!currentDeviceState) throw new Error("Device not found");
it("calls handleNodeRemove when 'Request New Keys' button is clicked", () => {
render(<RefreshKeysDialog open={true} onOpenChange={onOpenChangeMock} />);
fireEvent.click(screen.getByText("Request New Keys"));
expect(handleNodeRemoveMock).toHaveBeenCalled();
});
mockUseMessageStore.mockReturnValue({ activeChat: activeChatNum });
mockUseRefreshKeysDialog.mockReturnValue({
handleCloseDialog: vi.fn(),
handleNodeRemove: vi.fn(),
it("calls handleCloseDialog when 'Dismiss' button is clicked", () => {
render(<RefreshKeysDialog open={true} onOpenChange={onOpenChangeMock} />);
fireEvent.click(screen.getByText("Dismiss"));
expect(handleCloseDialogMock).toHaveBeenCalled();
});
const { container } = render(
<DeviceContext.Provider value={currentDeviceState}>
<RefreshKeysDialog open onOpenChange={vi.fn()} />
</DeviceContext.Provider>
);
it("calls onOpenChange when dialog close button is clicked", () => {
render(<RefreshKeysDialog open={true} onOpenChange={onOpenChangeMock} />);
fireEvent.click(screen.getByRole("button", { name: /close/i }));
expect(handleCloseDialogMock).toHaveBeenCalled();
});
expect(container.firstChild).toBeNull();
it("does not render when open is false", () => {
render(<RefreshKeysDialog open={false} onOpenChange={onOpenChangeMock} />);
expect(screen.queryByText("Keys Mismatch")).not.toBeInTheDocument();
});
});

24
src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx

@ -8,8 +8,6 @@ import {
import { Button } from "@components/UI/Button.tsx";
import { LockKeyholeOpenIcon } from "lucide-react";
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
export interface RefreshKeysDialogProps {
open: boolean;
@ -17,30 +15,16 @@ export interface RefreshKeysDialogProps {
}
export const RefreshKeysDialog = ({ open, onOpenChange }: RefreshKeysDialogProps) => {
const { activeChat } = useMessageStore();
const { nodeErrors, getNode } = useDevice();
const { handleCloseDialog, handleNodeRemove } = useRefreshKeysDialog();
const nodeErrorNum = nodeErrors.get(activeChat);
if (!nodeErrorNum) {
return null;
}
const nodeWithError = getNode(nodeErrorNum.node);
const text = {
title: `Keys Mismatch - ${nodeWithError?.user?.longName ?? ""}`,
description: `Your node is unable to send a direct message to node: ${nodeWithError?.user?.longName ?? ""} (${nodeWithError?.user?.shortName ?? ""}). This is due to the remote node's current public key does not match the previously stored key for this node.`,
}
const { handleCloseDialog, handleNodeRemove } = useRefreshKeysDialog();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-8 flex flex-col gap-2">
<DialogClose onClick={handleCloseDialog} />
<DialogHeader>
<DialogTitle>{text.title}</DialogTitle>
<DialogTitle>Keys Mismatch</DialogTitle>
</DialogHeader>
{text.description}
Your node is unable to send a direct message to this node. This is due to the remote node's current public key not matching the previously stored key for this node.
<ul className="mt-2">
<li className="flex place-items-center gap-2 items-start">
<div className="p-2 bg-slate-500 rounded-lg mt-1">
@ -56,12 +40,14 @@ export const RefreshKeysDialog = ({ open, onOpenChange }: RefreshKeysDialogProps
<Button
variant="default"
onClick={handleNodeRemove}
className=""
>
Request New Keys
</Button>
<Button
variant="outline"
onClick={handleCloseDialog}
className=""
>
Dismiss
</Button>

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

@ -2,12 +2,12 @@ import { renderHook, act } from "@testing-library/react";
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
import { beforeEach, describe, expect, it, Mock, vi } from "vitest";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useMessageStore } from "@core/stores/messageStore/index.ts";
vi.mock("@core/stores/messageStore", () => ({
useMessageStore: vi.fn(() => ({ activeChat: "chat-123" })),
vi.mock("@core/stores/appStore.ts", () => ({
useAppStore: vi.fn(() => ({ activeChat: "chat-123" })),
}));
vi.mock("@core/stores/deviceStore", () => ({
vi.mock("@core/stores/deviceStore.ts", () => ({
useDevice: vi.fn(() => ({
removeNode: vi.fn(),
setDialogOpen: vi.fn(),
@ -23,50 +23,46 @@ describe("useRefreshKeysDialog Hook", () => {
let clearNodeErrorMock: Mock;
beforeEach(() => {
vi.clearAllMocks();
removeNodeMock = vi.fn();
setDialogOpenMock = vi.fn();
getNodeErrorMock = vi.fn().mockReturnValue(undefined);
getNodeErrorMock = vi.fn();
clearNodeErrorMock = vi.fn();
vi.mocked(useDevice).mockReturnValue({
(useDevice as Mock).mockReturnValue({
removeNode: removeNodeMock,
setDialogOpen: setDialogOpenMock,
getNodeError: getNodeErrorMock,
clearNodeError: clearNodeErrorMock,
});
vi.mocked(useMessageStore).mockReturnValue({
activeChat: "chat-123"
});
});
it("handleNodeRemove should remove the node and update dialog if there is an error", () => {
getNodeErrorMock.mockReturnValue({ node: "node-abc" });
const { result } = renderHook(() => useRefreshKeysDialog());
act(() => { result.current.handleNodeRemove(); });
expect(getNodeErrorMock).toHaveBeenCalledTimes(1);
act(() => {
result.current.handleNodeRemove();
});
expect(getNodeErrorMock).toHaveBeenCalledWith("chat-123");
expect(clearNodeErrorMock).toHaveBeenCalledTimes(1);
expect(clearNodeErrorMock).toHaveBeenCalledWith("chat-123");
expect(removeNodeMock).toHaveBeenCalledTimes(1);
expect(removeNodeMock).toHaveBeenCalledWith("node-abc");
expect(setDialogOpenMock).toHaveBeenCalledTimes(1);
expect(setDialogOpenMock).toHaveBeenCalledWith("refreshKeys", false);
});
it("handleNodeRemove should do nothing if there is no error", () => {
getNodeErrorMock.mockReturnValue(undefined);
const { result } = renderHook(() => useRefreshKeysDialog());
act(() => { result.current.handleNodeRemove(); });
expect(getNodeErrorMock).toHaveBeenCalledTimes(1);
expect(getNodeErrorMock).toHaveBeenCalledWith("chat-123");
expect(clearNodeErrorMock).not.toHaveBeenCalled();
act(() => {
result.current.handleNodeRemove();
});
expect(removeNodeMock).not.toHaveBeenCalled();
expect(setDialogOpenMock).not.toHaveBeenCalled();
expect(clearNodeErrorMock).not.toHaveBeenCalled();
});
it("handleCloseDialog should close the dialog", () => {
@ -76,7 +72,6 @@ describe("useRefreshKeysDialog Hook", () => {
result.current.handleCloseDialog();
});
expect(setDialogOpenMock).toHaveBeenCalledTimes(1);
expect(setDialogOpenMock).toHaveBeenCalledWith("refreshKeys", false);
});
});
});

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

@ -1,14 +1,10 @@
import { useCallback } from "react";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useMessageStore } from "@core/stores/messageStore/index.ts";
export function useRefreshKeysDialog() {
const { removeNode, setDialogOpen, clearNodeError, getNodeError } = useDevice();
const { activeChat } = useMessageStore();
const handleCloseDialog = useCallback(() => {
setDialogOpen('refreshKeys', false);
}, [setDialogOpen]);
const { activeChat } = useAppStore();
const handleNodeRemove = useCallback(() => {
const nodeWithError = getNodeError(activeChat);
@ -16,12 +12,17 @@ export function useRefreshKeysDialog() {
return;
}
clearNodeError(activeChat);
handleCloseDialog();
handleCloseDialog();;
return removeNode(nodeWithError?.node);
}, [activeChat, clearNodeError, getNodeError, removeNode, handleCloseDialog]);
}, [activeChat, clearNodeError, setDialogOpen, removeNode]);
const handleCloseDialog = useCallback(() => {
setDialogOpen('refreshKeys', false);
}, [setDialogOpen])
return {
handleCloseDialog,
handleNodeRemove
};
}

4
src/components/Dialog/RemoveNodeDialog.tsx

@ -21,7 +21,7 @@ export const RemoveNodeDialog = ({
open,
onOpenChange,
}: RemoveNodeDialogProps) => {
const { connection, getNode, removeNode } = useDevice();
const { connection, nodes, removeNode } = useDevice();
const { nodeNumToBeRemoved } = useAppStore();
const onSubmit = () => {
@ -42,7 +42,7 @@ export const RemoveNodeDialog = ({
</DialogHeader>
<div className="gap-4">
<form onSubmit={onSubmit}>
<Label>{getNode(nodeNumToBeRemoved)?.user?.longName}</Label>
<Label>{nodes.get(nodeNumToBeRemoved)?.user?.longName}</Label>
</form>
</div>
<DialogFooter>

1
src/components/Dialog/ShutdownDialog.tsx

@ -41,6 +41,7 @@ export const ShutdownDialog = ({
type="number"
value={time}
onChange={(e) => setTime(Number.parseInt(e.target.value))}
className="dark:text-slate-900"
suffix="Minutes"
/>
<Button

10
src/components/Dialog/TracerouteResponseDialog.tsx

@ -23,17 +23,17 @@ export const TracerouteResponseDialog = ({
open,
onOpenChange,
}: TracerouteResponseDialogProps) => {
const { getNode } = useDevice();
const { nodes } = useDevice();
const route: number[] = traceroute?.data.route ?? [];
const routeBack: number[] = traceroute?.data.routeBack ?? [];
const snrTowards = (traceroute?.data.snrTowards ?? []).map(snr => snr / 4);
const snrBack = (traceroute?.data.snrBack ?? []).map(snr => snr / 4);
const from = getNode(traceroute?.from ?? 0);
const snrTowards = traceroute?.data.snrTowards ?? [];
const snrBack = traceroute?.data.snrBack ?? [];
const from = nodes.get(traceroute?.from ?? 0);
const longName = from?.user?.longName ??
(from ? `!${numberToHexUnpadded(from?.num)}` : "Unknown");
const shortName = from?.user?.shortName ??
(from ? `${numberToHexUnpadded(from?.num).substring(0, 4)}` : "UNK");
const to = getNode(traceroute?.to ?? 0);
const to = nodes.get(traceroute?.to ?? 0);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>

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

@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest';
import { renderHook } from '@testing-library/react';
import { useUnsafeRolesDialog, UNSAFE_ROLES } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
import { eventBus } from "@core/utils/eventBus.ts";
import { useUnsafeRolesDialog, UNSAFE_ROLES } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog";
import { eventBus } from "@core/utils/eventBus";
vi.mock('@core/utils/eventBus', () => ({
eventBus: {

31
src/components/Form/FormInput.tsx

@ -3,10 +3,11 @@ import type {
GenericFormElementProps,
} from "@components/Form/DynamicForm.tsx";
import { Input } from "@components/UI/Input.tsx";
import type { LucideIcon } from "lucide-react";
import { Eye, EyeOff } from "lucide-react";
import type { ChangeEventHandler } from "react";
import { useState } from "react";
import { useController, type FieldValues } from "react-hook-form";
import { cn } from "@core/utils/cn.ts";
export interface InputFieldProps<T> extends BaseFormBuilderProps<T> {
type: "text" | "number" | "password";
@ -16,15 +17,16 @@ export interface InputFieldProps<T> extends BaseFormBuilderProps<T> {
prefix?: string;
suffix?: string;
step?: number;
className?: string;
fieldLength?: {
min?: number;
max?: number;
currentValueLength?: number;
showCharacterCount?: boolean;
},
showPasswordToggle?: boolean;
showCopyButton?: boolean;
action?: {
icon: LucideIcon;
onClick: () => void;
};
};
}
@ -34,6 +36,8 @@ export function GenericInput<T extends FieldValues>({
field,
}: GenericFormElementProps<T, InputFieldProps<T>>) {
const { fieldLength, ...restProperties } = field.properties || {};
const [passwordShown, setPasswordShown] = useState(false);
const [currentLength, setCurrentLength] = useState<number>(fieldLength?.currentValueLength || 0);
const { field: controllerField } = useController({
@ -41,6 +45,10 @@ export function GenericInput<T extends FieldValues>({
control,
});
const togglePasswordVisiblity = () => {
setPasswordShown(!passwordShown);
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
@ -58,20 +66,25 @@ export function GenericInput<T extends FieldValues>({
return (
<div className="relative w-full">
<Input
type={field.type}
type={field.type === "password" && passwordShown ? "text" : field.type}
action={
field.type === "password"
? {
icon: passwordShown ? EyeOff : Eye,
onClick: togglePasswordVisiblity,
}
: undefined
}
step={field.properties?.step}
value={field.type === "number" ? String(controllerField.value) : controllerField.value}
id={field.name}
onChange={handleInputChange}
showCopyButton={field.properties?.showCopyButton}
showPasswordToggle={field.properties?.showPasswordToggle || field.type === "password"}
className={field.properties?.className}
{...restProperties}
disabled={disabled}
/>
{fieldLength?.showCharacterCount && fieldLength?.max && (
<div className="absolute inset-y-0 right-0 flex items-center pr-3 text-sm text-slate-900 dark:text-slate-200">
<div className="absolute inset-y-0 right-0 flex items-center pr-3 text-sm text-slate-500 dark:text-slate-400">
{currentLength ?? fieldLength?.currentValueLength}/{fieldLength?.max}
</div>
)}

22
src/components/Form/FormPasswordGenerator.tsx

@ -4,9 +4,10 @@ import type {
} from "@components/Form/DynamicForm.tsx";
import type { ButtonVariant } from "../UI/Button.tsx";
import { Generator } from "@components/UI/Generator.tsx";
import { Eye, EyeOff } from "lucide-react";
import type { ChangeEventHandler } from "react";
import { useState } from "react";
import { Controller, type FieldValues } from "react-hook-form";
import { usePasswordVisibilityToggle } from "@core/hooks/usePasswordVisibilityToggle.ts";
export interface PasswordGeneratorProps<T> extends BaseFormBuilderProps<T> {
type: "passwordGenerator";
@ -14,7 +15,7 @@ export interface PasswordGeneratorProps<T> extends BaseFormBuilderProps<T> {
hide?: boolean;
bits?: { text: string; value: string; key: string }[];
devicePSKBitCount: number;
inputChange: ChangeEventHandler<HTMLInputElement> | undefined;
inputChange: ChangeEventHandler;
selectChange: (event: string) => void;
actionButtons: {
text: string;
@ -22,8 +23,6 @@ export interface PasswordGeneratorProps<T> extends BaseFormBuilderProps<T> {
variant: ButtonVariant;
className?: string;
}[];
showPasswordToggle?: boolean;
showCopyButton?: boolean;
}
export function PasswordGenerator<T extends FieldValues>({
@ -31,7 +30,10 @@ export function PasswordGenerator<T extends FieldValues>({
field,
disabled,
}: GenericFormElementProps<T, PasswordGeneratorProps<T>>) {
const { isVisible } = usePasswordVisibilityToggle()
const [passwordShown, setPasswordShown] = useState(false);
const togglePasswordVisiblity = () => {
setPasswordShown(!passwordShown);
};
return (
<Controller
@ -39,8 +41,14 @@ export function PasswordGenerator<T extends FieldValues>({
control={control}
render={({ field: { value, ...rest } }) => (
<Generator
type={field.hide && !isVisible ? "password" : "text"}
type={field.hide && !passwordShown ? "password" : "text"}
id={field.id}
action={field.hide
? {
icon: passwordShown ? EyeOff : Eye,
onClick: togglePasswordVisiblity,
}
: undefined}
devicePSKBitCount={field.devicePSKBitCount}
bits={field.bits}
inputChange={field.inputChange}
@ -48,8 +56,6 @@ export function PasswordGenerator<T extends FieldValues>({
value={value}
variant={field.validationText ? "invalid" : "default"}
actionButtons={field.actionButtons}
showPasswordToggle={field.showPasswordToggle}
showCopyButton={field.showCopyButton}
{...field.properties}
{...rest}
disabled={disabled}

19
src/components/Form/FormWrapper.tsx

@ -20,16 +20,17 @@ export const FieldWrapper = ({
}: FieldWrapperProps) => (
<div className="pt-6 sm:pt-5">
<fieldset aria-labelledby="label-notifications">
{/* first column = labels/heading, second column = fields, third column = gutter */}
<div className="grid grid-cols-1 lg:grid-cols-[0.6fr_2fr_.1fr] sm:items-baseline gap-4">
<div className="sm:grid sm:grid-cols-3 sm:items-baseline sm:gap-4">
<Label htmlFor={fieldName}>{label}</Label>
<div className="max-w-3xl">
<p className="text-sm text-slate-500 dark:text-slate-400">{description}</p>
<p hidden={valid ?? true} className="text-sm text-red-500">
{validationText}
</p>
<div className="mt-4 space-y-4 sm:col-span-2">
<div className="flex items-center">{children}</div>
<div className="sm:col-span-2">
<div className="max-w-lg">
<p className="text-sm text-slate-500">{description}</p>
<p hidden={valid ?? true} className="text-sm text-red-500">
{validationText}
</p>
<div className="mt-4 space-y-4">
<div className="flex items-center">{children}</div>
</div>
</div>
</div>
</div>

12
src/components/PageComponents/Channel.tsx

@ -1,4 +1,4 @@
import type { ChannelValidation } from "@app/validation/channel.ts";
import type { ChannelValidation } from "@app/validation/channel.tsx";
import { create } from "@bufbuild/protobuf";
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import { useToast } from "@core/hooks/useToast.ts";
@ -97,8 +97,7 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
settings: {
...channel?.settings,
psk: pass,
moduleSettings: {
...channel?.settings?.moduleSettings,
moduleSettings: {...channel?.settings?.moduleSettings,
positionPrecision: channel?.settings?.moduleSettings?.positionPrecision === undefined ? 10 : channel?.settings?.moduleSettings?.positionPrecision,
}
},
@ -143,8 +142,6 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
hide: true,
properties: {
value: pass,
showPasswordToggle: true,
showCopyButton: true,
},
},
{
@ -209,11 +206,6 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
]}
/>
<PkiRegenerateDialog
text={{
button: "Regenerate",
title: "Regenerate Pre-Shared Key?",
description: "Are you sure you want to regenerate the pre-shared key?",
}}
open={preSharedDialogOpen}
onOpenChange={() => setPreSharedDialogOpen(false)}
onSubmit={() => preSharedKeyRegenerate()}

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

@ -1,5 +1,5 @@
import { useAppStore } from "../../../core/stores/appStore.ts";
import type { BluetoothValidation } from "@app/validation/config/bluetooth.ts";
import type { BluetoothValidation } from "@app/validation/config/bluetooth.tsx";
import { create } from "@bufbuild/protobuf";
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";

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

@ -5,11 +5,11 @@ import { useDevice } from "@core/stores/deviceStore.ts";
import { useUnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
import { Protobuf } from "@meshtastic/core";
vi.mock('@core/stores/deviceStore.ts', () => ({
vi.mock('@core/stores/deviceStore', () => ({
useDevice: vi.fn()
}));
vi.mock('@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts', () => ({
vi.mock('@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog', () => ({
useUnsafeRolesDialog: vi.fn()
}));

6
src/components/PageComponents/Config/Display.tsx

@ -61,12 +61,6 @@ export const Display = () => {
label: "Compass North Top",
description: "Fix north to the top of compass",
},
{
type: "toggle",
name: "use12hClock",
label: "12-Hour Clock",
description: "Use 12-hour clock format",
},
{
type: "toggle",
name: "flipScreen",

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

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

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

@ -195,8 +195,11 @@ export const Security = () => {
],
properties: {
value: state.privateKey,
showCopyButton: true,
showPasswordToggle: true,
action: {
icon: state.privateKeyVisible ? EyeOff : Eye,
onClick: () =>
dispatch({ type: "TOGGLE_PRIVATE_KEY_VISIBILITY" }),
},
},
},
{
@ -208,7 +211,6 @@ export const Security = () => {
"Sent out to other nodes on the mesh to allow them to compute a shared secret key",
properties: {
value: state.publicKey,
showCopyButton: true,
},
},
],
@ -269,7 +271,11 @@ export const Security = () => {
],
properties: {
value: state.adminKey,
showCopyButton: true,
action: {
icon: state.adminKeyVisible ? EyeOff : Eye,
onClick: () =>
dispatch({ type: "TOGGLE_ADMIN_KEY_VISIBILITY" }),
},
},
},
],
@ -296,11 +302,6 @@ export const Security = () => {
]}
/>
<PkiRegenerateDialog
text={{
button: "Regenerate",
title: "Regenerate Key pair?",
description: "Are you sure you want to regenerate key pair?",
}}
open={state.privateKeyDialogOpen}
onOpenChange={() =>
dispatch({ type: "SHOW_PRIVATE_KEY_DIALOG", payload: false })}

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

@ -7,12 +7,10 @@ import { subscribeAll } from "@core/subscriptions.ts";
import { randId } from "@core/utils/randId.ts";
import { BleConnection, ServiceUuid } from "@meshtastic/js";
import { useCallback, useEffect, useState } from "react";
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
export const BLE = ({ setConnectionInProgress, closeDialog }: TabElementProps) => {
export const BLE = ({ closeDialog }: TabElementProps) => {
const [bleDevices, setBleDevices] = useState<BluetoothDevice[]>([]);
const { addDevice } = useDeviceStore();
const messageStore = useMessageStore()
const { setSelectedDevice } = useAppStore();
const updateBleDeviceList = useCallback(async (): Promise<void> => {
@ -32,7 +30,7 @@ export const BLE = ({ setConnectionInProgress, closeDialog }: TabElementProps) =
device: bleDevice,
});
device.addConnection(connection);
subscribeAll(device, connection, messageStore);
subscribeAll(device, connection);
closeDialog();
};
@ -43,9 +41,8 @@ export const BLE = ({ setConnectionInProgress, closeDialog }: TabElementProps) =
{bleDevices.map((device) => (
<Button
key={device.id}
variant="default"
className="dark:bg-slate-900 dark:text-white"
onClick={() => {
setConnectionInProgress(true);
onConnect(device);
}}
>
@ -57,10 +54,8 @@ export const BLE = ({ setConnectionInProgress, closeDialog }: TabElementProps) =
)}
</div>
<Button
variant="default"
className="dark:bg-slate-900 dark:text-white"
onClick={async () => {
await navigator.bluetooth
.requestDevice({
filters: [{ services: [ServiceUuid] }],
@ -70,11 +65,6 @@ export const BLE = ({ setConnectionInProgress, closeDialog }: TabElementProps) =
if (exists === -1) {
setBleDevices(bleDevices.concat(device));
}
}).catch((error) => {
console.error("Error requesting device:", error);
setConnectionInProgress(false);
}).finally(() => {
setConnectionInProgress(false);
});
}}
>
@ -82,4 +72,4 @@ export const BLE = ({ setConnectionInProgress, closeDialog }: TabElementProps) =
</Button>
</div>
);
};
};

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

@ -1,7 +1,6 @@
import type { TabElementProps } from "@components/Dialog/NewDeviceDialog.tsx";
import { Button } from "@components/UI/Button.tsx";
import { Input } from "@components/UI/Input.tsx";
import { Link } from "@components/UI/Typography/Link.tsx";
import { Label } from "@components/UI/Label.tsx";
import { Switch } from "@components/UI/Switch.tsx";
import { useAppStore } from "@core/stores/appStore.ts";
@ -12,28 +11,25 @@ import { MeshDevice } from "@meshtastic/core";
import { TransportHTTP } from "@meshtastic/transport-http";
import { useState } from "react";
import { useForm, useController } from "react-hook-form";
import { AlertTriangle } from "lucide-react";
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
interface FormData {
ip: string;
tls: boolean;
}
export const HTTP = ({ closeDialog, setConnectionInProgress, connectionInProgress }: TabElementProps) => {
export const HTTP = ({ closeDialog }: TabElementProps) => {
const isURLHTTPS = location.protocol === "https:";
const { addDevice } = useDeviceStore();
const messageStore = useMessageStore();
const { setSelectedDevice } = useAppStore();
const { control, handleSubmit, register } = useForm<FormData>({
defaultValues: {
ip: ["client.meshtastic.org", "localhost"].includes(
globalThis.location.hostname,
window.location.hostname,
)
? "meshtastic.local"
: globalThis.location.host,
: window.location.host,
tls: isURLHTTPS ? true : false,
},
});
@ -42,85 +38,49 @@ export const HTTP = ({ closeDialog, setConnectionInProgress, connectionInProgres
field: { value: tlsValue, onChange: setTLS },
} = useController({ name: "tls", control });
const [connectionError, setConnectionError] = useState<{ host: string; secure: boolean } | null>(null);
const [connectionInProgress, setConnectionInProgress] = useState(false);
const onSubmit = handleSubmit(async (data) => {
setConnectionInProgress(true);
setConnectionError(null);
try {
const id = randId();
const transport = await TransportHTTP.create(data.ip, data.tls);
const device = addDevice(id);
const connection = new MeshDevice(transport, id);
connection.configure();
setSelectedDevice(id);
device.addConnection(connection);
subscribeAll(device, connection, messageStore);
closeDialog();
} catch (error) {
console.error("Connection error:", error);
// Capture all connection errors regardless of type
setConnectionError({ host: data.ip, secure: data.tls });
setConnectionInProgress(false);
}
const id = randId();
const device = addDevice(id);
const transport = await TransportHTTP.create(data.ip, data.tls);
const connection = new MeshDevice(transport, id);
connection.configure();
setSelectedDevice(id);
device.addConnection(connection);
subscribeAll(device, connection);
closeDialog();
});
return (
<form className="flex w-full flex-col gap-2 p-4" onSubmit={onSubmit}>
<div className="flex flex-col gap-2" style={{ minHeight: "12rem" }}>
<div className="flex h-48 flex-col gap-2">
<div>
<Label>IP Address/Hostname</Label>
<Input
prefix={tlsValue ? "https://" : "http://"}
placeholder="000.000.000.000 / meshtastic.local"
className="text-slate-900 dark:text-slate-100"
className="text-slate-900 dark:text-slate-900"
disabled={connectionInProgress}
{...register("ip")}
/>
</div>
<div className="flex items-center gap-2 mt-2">
<Switch
onCheckedChange={setTLS}
disabled={isURLHTTPS}
disabled={isURLHTTPS || connectionInProgress}
checked={isURLHTTPS || tlsValue}
{...register("tls")}
/>
<Label>Use HTTPS</Label>
</div>
{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="flex gap-2 items-start">
<AlertTriangle className="shrink-0 mt-0.5 text-amber-600 dark:text-amber-600" size={20} />
<div>
<p className="text-sm font-medium text-amber-800 dark:text-amber-800">
Connection Failed
</p>
<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. "}
Please open{" "}
<Link
href={`${connectionError.secure ? "https" : "http"}://${connectionError.host}`}
className="underline font-medium text-amber-800 dark:text-amber-800"
>
{`${connectionError.secure ? "https" : "http"}://${connectionError.host}`}
</Link>{" "}
in a new tab{connectionError.secure ? ", accept any TLS warnings if prompted, then try again" : ""}.{" "}
<Link
href="https://meshtastic.org/docs/software/web-client/#http"
className="underline font-medium text-amber-800 dark:text-amber-800"
>
Learn more
</Link>
</p>
</div>
</div>
</div>
)}
</div>
</div>
<Button
type="submit"
variant={"default"}
disabled={connectionInProgress}
className="dark:bg-slate-900 dark:text-white"
>
<span>{connectionInProgress ? "Connecting..." : "Connect"}</span>
</Button>

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

@ -8,12 +8,10 @@ import { randId } from "@core/utils/randId.ts";
import { MeshDevice } from "@meshtastic/core";
import { TransportWebSerial } from "@meshtastic/transport-web-serial";
import { useCallback, useEffect, useState } from "react";
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
export const Serial = ({ setConnectionInProgress, closeDialog }: TabElementProps) => {
export const Serial = ({ closeDialog }: TabElementProps) => {
const [serialPorts, setSerialPorts] = useState<SerialPort[]>([]);
const { addDevice } = useDeviceStore();
const messageStore = useMessageStore()
const { setSelectedDevice } = useAppStore();
const updateSerialPortList = useCallback(async () => {
@ -38,7 +36,7 @@ export const Serial = ({ setConnectionInProgress, closeDialog }: TabElementProps
const connection = new MeshDevice(transport, id);
connection.configure();
device.addConnection(connection);
subscribeAll(device, connection, messageStore);
subscribeAll(device, connection);
closeDialog();
};
@ -52,9 +50,8 @@ export const Serial = ({ setConnectionInProgress, closeDialog }: TabElementProps
<Button
key={`${usbVendorId ?? "UNK"}-${usbProductId ?? "UNK"}-${index}`}
disabled={port.readable !== null}
variant="default"
className="dark:bg-slate-900 dark:text-white"
onClick={async () => {
setConnectionInProgress(true);
await onConnect(port);
}}
>
@ -68,15 +65,10 @@ export const Serial = ({ setConnectionInProgress, closeDialog }: TabElementProps
)}
</div>
<Button
variant="default"
className="dark:bg-slate-900 dark:text-white"
onClick={async () => {
await navigator.serial.requestPort().then((port) => {
setSerialPorts(serialPorts.concat(port));
}).catch((error) => {
console.error("Error requesting port:", error);
setConnectionInProgress(false);
}).finally(() => {
setConnectionInProgress(false);
});
}}
>

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

@ -7,7 +7,12 @@ import { Mono } from "@components/generic/Mono.tsx";
import { TimeAgo } from "@components/generic/TimeAgo.tsx";
import { Protobuf } from "@meshtastic/core";
import type { Protobuf as ProtobufType } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import {
BatteryChargingIcon,
BatteryFullIcon,
BatteryLowIcon,
BatteryMediumIcon,
Dot,
LockIcon,
LockOpenIcon,
@ -22,35 +27,34 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@radix-ui/react-tooltip";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDevice } from "@core/stores/deviceStore.ts";
import { MessageType, useMessageStore } from "../../../core/stores/messageStore/index.ts";
import BatteryStatus from "@components/BatteryStatus.tsx";
export interface NodeDetailProps {
node: ProtobufType.Mesh.NodeInfo;
}
export const NodeDetail = ({ node }: NodeDetailProps) => {
const { setChatType, setActiveChat } = useMessageStore();
const { setChatType, setActiveChat } = useAppStore();
const { setActivePage } = useDevice();
const name = node.user?.longName ?? `UNK`;
const name = node.user?.longName || `!${numberToHexUnpadded(node.num)}`;
const shortName = node.user?.shortName ?? "UNK";
const hwModel = node.user?.hwModel ?? 0;
const hardwareType =
Protobuf.Mesh.HardwareModel[hwModel]?.replaceAll("_", " ") ?? `${hwModel}`;
function handleDirectMessage() {
setChatType(MessageType.Direct);
setChatType("direct");
setActiveChat(node.num);
setActivePage("messages");
}
return (
<div className="p-1 text-slate-900">
<div className="dark:text-slate-900 p-1">
<div className="flex gap-2">
<div className="flex flex-col items-center gap-2 min-w-6 pt-1">
<Avatar text={shortName} size="sm" />
<Avatar text={shortName} />
<div onFocusCapture={(e) => {
// Required to prevent DM tooltip auto-appearing on creation
e.stopPropagation();
@ -76,10 +80,11 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<MessageSquareIcon
<MessageSquareIcon
size={14}
onClick={handleDirectMessage}
className="cursor-pointer hover:text-blue-500"
title="Send Message"
/>
</TooltipTrigger>
<TooltipPortal>
@ -109,7 +114,24 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
{hardwareType !== "UNSET" && <Subtle>{hardwareType}</Subtle>}
{!!node.deviceMetrics?.batteryLevel && (
<BatteryStatus deviceMetrics={node.deviceMetrics} />
<div
className="flex items-center gap-1 mt-0.5"
title={`${node.deviceMetrics?.voltage?.toPrecision(3) ?? "Unknown"
} volts`}
>
{node.deviceMetrics?.batteryLevel > 100
? <BatteryChargingIcon size={22} />
: node.deviceMetrics?.batteryLevel > 80
? <BatteryFullIcon size={22} />
: node.deviceMetrics?.batteryLevel > 20
? <BatteryMediumIcon size={22} />
: <BatteryLowIcon size={22} />}
<Subtle aria-label="Battery">
{node.deviceMetrics?.batteryLevel > 100
? "Charging"
: `${node.deviceMetrics?.batteryLevel}%`}
</Subtle>
</div>
)}
<div className="flex gap-2 items-center">
@ -177,7 +199,7 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
{!!node.deviceMetrics?.airUtilTx && (
<div className="grow">
<div>Airtime Util</div>
<Mono className="text-gray-500">{node.deviceMetrics?.airUtilTx.toPrecision(3)}%</Mono>
<Mono>{node.deviceMetrics?.airUtilTx.toPrecision(3)}%</Mono>
</div>
)}
</div>
@ -185,7 +207,7 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
{node.snr !== 0 && (
<div className="mt-2">
<div>SNR</div>
<Mono className="flex items-center text-xs text-gray-500">
<Mono className="flex items-center text-xs">
{node.snr}db
<Dot />
{Math.min(Math.max((node.snr + 10) * 5, 0), 100)}%

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

@ -1,77 +1,77 @@
import { MessageItem } from "@components/PageComponents/Messages/MessageItem.tsx";
import { type MessageWithState, useDevice } from "@core/stores/deviceStore.ts";
import { Message } from "@components/PageComponents/Messages/Message.tsx";
import { InboxIcon } from "lucide-react";
import { useCallback, useEffect, useRef } from "react";
import { Message } from "@core/stores/messageStore/types.ts";
export interface ChannelChatProps {
messages?: Message[];
messages?: MessageWithState[];
}
const EmptyState = () => (
<div className="flex flex-1 flex-col place-content-center place-items-center p-8 text-slate-500 dark:text-slate-400">
<InboxIcon className="mb-2 h-8 w-8" />
<div className="flex flex-col place-content-center place-items-center p-8 text-white">
<InboxIcon className="h-8 w-8 mb-2" />
<span className="text-sm">No Messages</span>
</div>
);
export const ChannelChat = ({ messages = [] }: ChannelChatProps) => {
const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLUListElement>(null);
const userScrolledUpRef = useRef(false);
export const ChannelChat = ({
messages = [],
}: ChannelChatProps) => {
const { nodes } = useDevice();
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => {
requestAnimationFrame(() => {
messagesEndRef.current?.scrollIntoView({ behavior });
});
}, []);
const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const scrollToBottom = useCallback(() => {
const scrollContainer = scrollContainerRef.current;
if (!scrollContainer) return;
const isScrolledToBottom = scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight <= 10;
if (scrollContainer) {
const isNearBottom =
scrollContainer.scrollHeight -
scrollContainer.scrollTop -
scrollContainer.clientHeight <
100;
if (isScrolledToBottom || !userScrolledUpRef.current) {
scrollToBottom('smooth');
if (isNearBottom) {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}
}
}, [messages, scrollToBottom]);
}, []);
useEffect(() => {
const scrollContainer = scrollContainerRef.current;
const handleScroll = () => {
if (!scrollContainer) return;
const isAtBottom = scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight <= 10;
userScrolledUpRef.current = !isAtBottom;
};
scrollContainer?.addEventListener('scroll', handleScroll, { passive: true });
return () => {
scrollContainer?.removeEventListener('scroll', handleScroll);
};
}, []);
scrollToBottom();
}, [scrollToBottom, messages]);
if (!messages?.length) {
return (
<div className="flex flex-1 flex-col items-center justify-center">
<EmptyState />
<div ref={messagesEndRef} />
<div className="flex flex-col h-full container mx-auto">
<div className="flex-1 flex items-center justify-center">
<EmptyState />
</div>
</div>
);
}
return (
<ul
ref={scrollContainerRef}
className="flex flex-col flex-grow overflow-y-auto px-3 py-2"
>
<div className="flex-grow" />
{messages?.map((message) => (
<MessageItem
key={message.messageId ?? `${message.from}-${message.date}`}
message={message}
/>
))}
<div className="flex flex-col h-full container mx-auto">
<div
ref={scrollContainerRef}
className="flex-1 overflow-y-auto pl-4 pr-4 md:pr-44"
>
<div className="flex flex-col justify-end min-h-full">
{messages?.map((message, index) => (
<Message
key={message.id}
message={message}
sender={nodes.get(message.from)}
lastMsgSameUser={
index > 0 && messages[index - 1].from === message.from
}
/>
))}
<div ref={messagesEndRef} className="w-full" />
</div>
</div>
<div ref={messagesEndRef} className="h-px" />
</ul>
</div>
);
};

175
src/components/PageComponents/Messages/Message.tsx

@ -0,0 +1,175 @@
import { memo, useMemo } from "react";
import {
Tooltip,
TooltipArrow,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@components/UI/Tooltip.tsx";
import {
type MessageWithState,
useDeviceStore,
} from "@core/stores/deviceStore.ts";
import { cn } from "@core/utils/cn.ts";
import { Avatar } from "@components/UI/Avatar.tsx";
import type { Protobuf } from "@meshtastic/core";
import { AlertCircle, CheckCircle2, CircleEllipsis, LucideIcon } from "lucide-react";
type MessageStateValue = {
state: string;
icon: LucideIcon;
displayText: string;
}
type MessageState = MessageWithState["state"];
interface MessageProps {
lastMsgSameUser: boolean;
message: MessageWithState;
sender: Protobuf.Mesh.NodeInfo;
}
interface StatusTooltipProps {
state: MessageState;
children: React.ReactNode;
}
interface StatusIconProps {
state: MessageState;
className?: string;
}
const MESSAGE_STATES: Record<string, MessageStateValue> = {
ACK: { state: 'ack', icon: CheckCircle2, displayText: "Message delivered" },
WAITING: { state: 'waiting', icon: CircleEllipsis, displayText: "Waiting for delivery" },
FAILED: { state: 'failed', icon: AlertCircle, displayText: "Delivery failed" },
};
const getMessageState = (state: MessageState): MessageStateValue => {
switch (state) {
case MESSAGE_STATES.ACK.state:
return MESSAGE_STATES.ACK;
case MESSAGE_STATES.WAITING.state:
return MESSAGE_STATES.WAITING;
case MESSAGE_STATES.FAILED.state:
return MESSAGE_STATES.FAILED;
default:
return MESSAGE_STATES.FAILED;
}
}
const StatusTooltip = ({ state, children }: StatusTooltipProps) => (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent
className="rounded-md bg-slate-800 px-3 py-1.5 text-sm text-white dark:text-white shadow-md animate-in fade-in-0 zoom-in-95"
side="top"
align="center"
sideOffset={5}
>
{getMessageState(state).displayText ?? "An unknown error occurred"};
<TooltipArrow className="fill-slate-800" />
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
const StatusIcon = ({ state, className, ...otherProps }: StatusIconProps) => {
const msgState = getMessageState(state);
const isFailed = msgState.state === 'failed'
const iconClass = cn(
className,
"text-slate-500 dark:text-slate-400 size-5 shrink-0"
);
const Icon = msgState.icon;
return (
<StatusTooltip state={state}>
<Icon
className={iconClass}
{...otherProps}
color={isFailed ? "red" : "currentColor"}
/>
</StatusTooltip>
);
};
const TimeDisplay = memo(({ date, className }: { date: Date; className?: string }) => (
<div className={cn("flex items-center gap-2 shrink-0", className)}>
<span className="text-xs text-slate-500 dark:text-slate-400 font-mono">
{date.toLocaleDateString()}
</span>
<span className="text-xs text-slate-500 dark:text-slate-400 font-mono">
{date.toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
})}
</span>
</div>
));
export const Message = memo(({ lastMsgSameUser, message, sender }: MessageProps) => {
const { getDevices } = useDeviceStore();
const isDeviceUser = useMemo(
() =>
getDevices()
.map((device) => device.nodes.get(device.hardware.myNodeNum)?.num)
.includes(message.from),
[getDevices, message.from]
);
const messageUser = sender?.user;
const getMessageTextStyles = (state: MessageState) => {
const msgState = getMessageState(state);
const isAcknowledged = msgState.state === 'ack'
const isFailed = msgState.state === 'failed'
return cn(
"break-words overflow-hidden",
isAcknowledged
? "text-slate-900 dark:text-white"
: "text-slate-900 dark:text-slate-400",
isFailed && "text-red-500 dark:text-red-500",
);
};
const messageTextClass = useMemo(() => getMessageTextStyles(message.state), [message.state]);
return (
<div className="flex flex-col w-full px-4 justify-start">
<div
className={cn(
"flex flex-col flex-wrap items-start py-1",
isDeviceUser && "items-end"
)}
>
<div className="flex items-center gap-2 mb-2">
{!lastMsgSameUser && (
<div className="flex place-items-center gap-2 mb-1">
<Avatar text={messageUser?.shortName ?? "UNK"} />
<div className="flex flex-col">
<span className="font-medium text-slate-900 dark:text-white truncate">
{messageUser?.longName}
</span>
</div>
</div>
)}
</div>
<TimeDisplay date={message.rxTime} />
<div className="flex place-items-center gap-2 pb-2">
<div className={cn(isDeviceUser && "pl-11", messageTextClass)}>
{message.data}
</div>
<StatusIcon state={message.state} />
</div>
</div>
</div>
);
});

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

@ -1,91 +0,0 @@
import {
Tooltip,
TooltipArrow,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@components/UI/Tooltip.tsx";
import { cn } from "@core/utils/cn.ts";
import { SmilePlus, Reply } from "lucide-react";
interface MessageActionsMenuProps {
onAddReaction?: () => void;
onReply?: () => void;
}
export const MessageActionsMenu = ({
onAddReaction,
onReply
}: MessageActionsMenuProps) => {
const hoverIconBarClass = cn(
"absolute top-2 right-2",
"flex items-center gap-x-1",
"bg-white dark:bg-zinc-800",
"border border-gray-200 dark:border-zinc-600",
"rounded-md shadow-sm p-1",
"opacity-0 group-hover:opacity-100",
"transition-opacity duration-100 ease-in-out",
"z-10"
);
const hoverIconButtonClass = cn(
"p-1 rounded",
"text-gray-500 dark:text-gray-400",
"hover:text-gray-700 dark:hover:text-gray-300",
"hover:bg-gray-100 dark:hover:bg-zinc-700",
"cursor-pointer"
);
const iconSizeClass = "size-4";
return (
<div className={cn(hoverIconBarClass)} onClick={(e) => e.stopPropagation()}>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label="Add Reaction"
onClick={(e) => {
e.stopPropagation()
if (onAddReaction) {
onAddReaction();
}
}}
className={hoverIconButtonClass}
>
<SmilePlus className={iconSizeClass} aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent className="bg-gray-800 text-white px-2 py-1 rounded text-xs">
Add Reaction
<TooltipArrow className="fill-gray-800" />
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label="Reply"
onClick={(e) => {
e.stopPropagation()
if (onReply) {
onReply();
}
}}
className={hoverIconButtonClass}
>
<Reply className={iconSizeClass} aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent className="bg-gray-800 text-white px-2 py-1 rounded text-xs">
Reply
<TooltipArrow className="fill-gray-800" />
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
};

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

@ -1,222 +1,152 @@
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { MessageInput, MessageInputProps } from './MessageInput.tsx';
import { Types } from '@meshtastic/core';
vi.mock('@components/UI/Button.tsx', () => ({
Button: vi.fn(({ type, className, children, onClick, onSubmit, variant, ...rest }) => (
<button type={type} className={className} onClick={onClick} onSubmit={onSubmit} {...rest}>
{children}
</button>
)),
import { MessageInput } from '@components/PageComponents/Messages/MessageInput.tsx';
import { useDevice } from "@core/stores/deviceStore.ts";
import { vi, describe, it, expect, beforeEach, Mock } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
vi.mock("@core/stores/deviceStore.ts", () => ({
useDevice: vi.fn(),
}));
vi.mock('@components/UI/Input.tsx', () => ({
Input: vi.fn(({ autoFocus, minLength, name, placeholder, value, onChange }) => (
<input
autoFocus={autoFocus}
minLength={minLength}
name={name}
placeholder={placeholder}
value={value}
onChange={onChange}
data-testid="message-input-field"
/>
)),
vi.mock("@core/utils/debounce.ts", () => ({
debounce: (fn: () => void) => fn,
}));
const mockSetDraft = vi.fn();
const mockGetDraft = vi.fn();
const mockClearDraft = vi.fn();
vi.mock('@core/stores/messageStore', () => ({
useMessageStore: vi.fn(() => ({
setDraft: mockSetDraft,
getDraft: mockGetDraft,
clearDraft: mockClearDraft,
})),
MessageState: {
Ack: 'ack',
Waiting: 'waiting',
Failed: 'failed',
},
MessageType: {
Direct: 'direct',
Broadcast: 'broadcast',
},
vi.mock("@components/UI/Button.tsx", () => ({
Button: ({ children, ...props }: { children: React.ReactNode }) => <button {...props}>{children}</button>
}));
vi.mock('lucide-react', () => ({
SendIcon: vi.fn(() => <svg data-testid="send-icon" />),
vi.mock("@components/UI/Input.tsx", () => ({
Input: (props: any) => <input {...props} />
}));
describe('MessageInput', () => {
const mockOnSend = vi.fn();
const defaultProps: MessageInputProps = {
onSend: mockOnSend,
to: 123,
maxBytes: 256,
vi.mock("lucide-react", () => ({
SendIcon: () => <div data-testid="send-icon">Send</div>
}));
// TODO: getting an error with this test
describe('MessageInput Component', () => {
const mockProps = {
to: "broadcast" as const,
channel: 0 as const,
maxBytes: 100,
};
const mockSetMessageDraft = vi.fn();
const mockSetMessageState = vi.fn();
const mockSendText = vi.fn().mockResolvedValue(123);
beforeEach(() => {
vi.clearAllMocks();
mockGetDraft.mockReturnValue('');
(useDevice as Mock).mockReturnValue({
connection: {
sendText: mockSendText,
},
setMessageState: mockSetMessageState,
messageDraft: "",
setMessageDraft: mockSetMessageDraft,
hardware: {
myNodeNum: 1234567890,
},
});
});
const renderComponent = (props: Partial<MessageInputProps> = {}) => {
render(<MessageInput {...defaultProps} {...props} />);
};
it('renders correctly with initial state', () => {
render(<MessageInput {...mockProps} />);
it('should render the input field, byte counter, and send button', () => {
renderComponent();
expect(screen.getByPlaceholderText('Enter Message')).toBeInTheDocument();
expect(screen.getByTestId('byte-counter')).toBeInTheDocument();
expect(screen.getByRole('button')).toBeInTheDocument();
expect(screen.getByTestId('send-icon')).toBeInTheDocument();
});
it('should initialize with the draft from the store', () => {
const initialDraft = 'Existing draft message';
mockGetDraft.mockImplementation((key) => {
return key === defaultProps.to ? initialDraft : '';
});
renderComponent();
const inputElement = screen.getByPlaceholderText('Enter Message') as HTMLInputElement;
expect(inputElement.value).toBe(initialDraft);
expect(mockGetDraft).toHaveBeenCalledWith(defaultProps.to);
const expectedBytes = new Blob([initialDraft]).size;
expect(screen.getByTestId('byte-counter')).toHaveTextContent(`${expectedBytes}/${defaultProps.maxBytes}`);
expect(screen.getByText('0/100')).toBeInTheDocument();
});
it('should update input value, byte counter, and call setDraft on change within limits', () => {
renderComponent();
const inputElement = screen.getByPlaceholderText('Enter Message');
const testMessage = 'Hello there!';
const expectedBytes = new Blob([testMessage]).size;
it('updates local draft and byte count when typing', () => {
render(<MessageInput {...mockProps} />);
fireEvent.change(inputElement, { target: { value: testMessage } });
const inputField = screen.getByPlaceholderText('Enter Message');
fireEvent.change(inputField, { target: { value: 'Hello' } })
expect((inputElement as HTMLInputElement).value).toBe(testMessage);
expect(screen.getByTestId('byte-counter')).toHaveTextContent(`${expectedBytes}/${defaultProps.maxBytes}`);
expect(mockSetDraft).toHaveBeenCalledTimes(1);
expect(mockSetDraft).toHaveBeenCalledWith(defaultProps.to, testMessage);
expect(screen.getByText('5/100')).toBeInTheDocument();
expect(inputField).toHaveValue('Hello');
expect(mockSetMessageDraft).toHaveBeenCalledWith('Hello');
});
it('should NOT update input value or call setDraft if maxBytes is exceeded', () => {
const smallMaxBytes = 5;
renderComponent({ maxBytes: smallMaxBytes });
const inputElement = screen.getByPlaceholderText('Enter Message');
const initialValue = '12345';
const excessiveValue = '123456';
it.skip('does not allow input exceeding max bytes', () => {
render(<MessageInput {...mockProps} maxBytes={5} />);
fireEvent.change(inputElement, { target: { value: initialValue } });
expect((inputElement as HTMLInputElement).value).toBe(initialValue);
expect(mockSetDraft).toHaveBeenCalledWith(defaultProps.to, initialValue);
mockSetDraft.mockClear();
const inputField = screen.getByPlaceholderText('Enter Message');
fireEvent.change(inputElement, { target: { value: excessiveValue } });
expect(screen.getByText('0/100')).toBeInTheDocument();
expect((inputElement as HTMLInputElement).value).toBe(initialValue);
expect(screen.getByTestId('byte-counter')).toHaveTextContent(`${smallMaxBytes}/${smallMaxBytes}`);
expect(mockSetDraft).not.toHaveBeenCalled();
});
userEvent.type(inputField, 'Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis p')
it('should call onSend, clear input, reset byte counter, and call clearDraft on valid submit', async () => {
renderComponent();
const inputElement = screen.getByPlaceholderText('Enter Message');
const formElement = screen.getByRole('form');
const testMessage = 'Send this message';
fireEvent.change(inputElement, { target: { value: testMessage } });
fireEvent.submit(formElement);
await waitFor(() => {
expect(mockOnSend).toHaveBeenCalledTimes(1);
expect(mockOnSend).toHaveBeenCalledWith(testMessage);
expect((inputElement as HTMLInputElement).value).toBe('');
expect(screen.getByTestId('byte-counter')).toHaveTextContent(`0/${defaultProps.maxBytes}`);
expect(mockClearDraft).toHaveBeenCalledTimes(1);
expect(mockClearDraft).toHaveBeenCalledWith(defaultProps.to);
});
expect(screen.getByText('100/100')).toBeInTheDocument();
expect(inputField).toHaveValue('Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean m');
});
it('should trim whitespace before calling onSend', async () => {
renderComponent();
const inputElement = screen.getByPlaceholderText('Enter Message');
const formElement = screen.getByRole('form');
const testMessageWithWhitespace = ' Trim me! ';
const expectedTrimmedMessage = 'Trim me!';
it.skip('sends message and resets form when submitting', async () => {
try {
render(<MessageInput {...mockProps} />);
fireEvent.change(inputElement, { target: { value: testMessageWithWhitespace } });
fireEvent.submit(formElement);
const inputField = screen.getByPlaceholderText('Enter Message');
const submitButton = screen.getByText('Send');
await waitFor(() => {
expect(mockOnSend).toHaveBeenCalledTimes(1);
expect(mockOnSend).toHaveBeenCalledWith(expectedTrimmedMessage);
expect(mockClearDraft).toHaveBeenCalledWith(defaultProps.to);
});
});
fireEvent.change(inputField, { target: { value: 'Test Message' } });
fireEvent.click(submitButton);
it('should not call onSend or clearDraft if input is empty on submit', async () => {
renderComponent();
const inputElement = screen.getByPlaceholderText('Enter Message');
const formElement = screen.getByRole('form');
const form = screen.getByRole('form');
fireEvent.submit(form);
expect((inputElement as HTMLInputElement).value).toBe('');
expect(mockSendText).toHaveBeenCalledWith('Test message', 'broadcast', true, 0);
fireEvent.submit(formElement);
await waitFor(() => {
expect(mockSetMessageState).toHaveBeenCalledWith(
'broadcast',
0,
'broadcast',
1234567890,
123,
'ack'
);
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 50));
});
});
expect(mockOnSend).not.toHaveBeenCalled();
expect(mockClearDraft).not.toHaveBeenCalled();
expect(inputField).toHaveValue('');
expect(screen.getByText('0/100')).toBeInTheDocument();
expect(mockSetMessageDraft).toHaveBeenCalledWith('');
} catch (e) {
console.error(e);
}
});
it('prevents sending empty messages', () => {
render(<MessageInput {...mockProps} />);
it('should not call onSend or clearDraft if input contains only whitespace on submit', async () => {
renderComponent();
const inputElement = screen.getByTestId('message-input-field');
const formElement = screen.getByRole('form');
const whitespaceMessage = ' \t ';
fireEvent.change(inputElement, { target: { value: whitespaceMessage } });
expect((inputElement as HTMLInputElement).value).toBe(whitespaceMessage);
fireEvent.submit(formElement);
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 50));
});
const form = screen.getByPlaceholderText('Enter Message')
fireEvent.submit(form);
expect(mockOnSend).not.toHaveBeenCalled();
expect(mockClearDraft).not.toHaveBeenCalled();
expect((inputElement as HTMLInputElement).value).toBe(whitespaceMessage);
expect(mockSendText).not.toHaveBeenCalled();
});
it('should work with broadcast destination for drafts', () => {
const broadcastDest: Types.Destination = 'broadcast';
mockGetDraft.mockImplementation((key) => key === broadcastDest ? 'Broadcast draft' : '');
renderComponent({ to: broadcastDest });
expect(mockGetDraft).toHaveBeenCalledWith(broadcastDest);
expect((screen.getByPlaceholderText('Enter Message') as HTMLInputElement).value).toBe('Broadcast draft');
const inputElement = screen.getByPlaceholderText('Enter Message');
const formElement = screen.getByRole('form');
const newMessage = 'New broadcast msg';
it('initializes with existing message draft', () => {
(useDevice as Mock).mockReturnValue({
connection: {
sendText: mockSendText,
},
setMessageState: mockSetMessageState,
messageDraft: "Existing draft",
setMessageDraft: mockSetMessageDraft,
isQueueingMessages: false,
queueStatus: { free: 10 },
hardware: {
myNodeNum: 1234567890,
},
});
fireEvent.change(inputElement, { target: { value: newMessage } });
expect(mockSetDraft).toHaveBeenCalledWith(broadcastDest, newMessage);
render(<MessageInput {...mockProps} />);
fireEvent.submit(formElement);
const inputField = screen.getByRole('textbox');
expect(mockOnSend).toHaveBeenCalledWith(newMessage);
expect(mockClearDraft).toHaveBeenCalledWith(broadcastDest);
expect(inputField).toHaveValue('Existing draft');
});
});

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

@ -1,57 +1,101 @@
import { debounce } from "@core/utils/debounce.ts";
import { Button } from "@components/UI/Button.tsx";
import { Input } from "@components/UI/Input.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import type { Types } from "@meshtastic/core";
import { SendIcon } from "lucide-react";
import { startTransition, useState } from "react";
import { useMessageStore } from "@core/stores/messageStore/index.ts";
import { startTransition, useCallback, useMemo, useState } from "react";
export interface MessageInputProps {
onSend: (message: string) => void;
to: Types.Destination;
channel: Types.ChannelNumber;
maxBytes: number;
}
export const MessageInput = ({
onSend,
to,
channel,
maxBytes,
}: MessageInputProps) => {
const { setDraft, getDraft, clearDraft } = useMessageStore();
const {
connection,
setMessageState,
messageDraft,
setMessageDraft,
isQueueingMessages,
queueStatus,
hardware,
} = useDevice();
const myNodeNum = hardware.myNodeNum;
const [localDraft, setLocalDraft] = useState(messageDraft);
const [messageBytes, setMessageBytes] = useState(0);
const calculateBytes = (text: string) => new Blob([text]).size;
const debouncedSetMessageDraft = useMemo(
() => debounce(setMessageDraft, 300),
[setMessageDraft],
);
// sends the message to the selected destination
const sendText = useCallback(
async (message: string) => {
const initialDraft = getDraft(to);
const [localDraft, setLocalDraft] = useState(initialDraft);
const [messageBytes, setMessageBytes] = useState(() => calculateBytes(initialDraft));
await connection
?.sendText(message, to, true, channel)
.then((id: number) =>
setMessageState(
to === "broadcast" ? "broadcast" : "direct",
channel,
to as number,
myNodeNum,
id,
"ack",
)
)
.catch((e: Types.PacketError) =>
setMessageState(
to === "broadcast" ? "broadcast" : "direct",
channel,
to as number,
myNodeNum,
e.id,
e.error,
)
);
},
[channel, connection, myNodeNum, setMessageState, to, queueStatus],
);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
const byteLength = calculateBytes(newValue);
const byteLength = new Blob([newValue]).size;
if (byteLength <= maxBytes) {
setLocalDraft(newValue);
debouncedSetMessageDraft(newValue);
setMessageBytes(byteLength);
setDraft(to, newValue);
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!localDraft.trim()) return;
// Reset bytes *before* sending (consider if onSend failure needs different handling)
setMessageBytes(0);
startTransition(() => {
onSend(localDraft.trim());
setLocalDraft("");
clearDraft(to);
});
};
return (
<div className="flex gap-2">
<form className="w-full" name="messageInput" onSubmit={handleSubmit}>
<div className="flex grow gap-1">
<form
className="w-full"
action={(formData: FormData) => {
// prevent user from sending blank/empty message
if (localDraft === "") return;
const message = formData.get("messageInput") as string;
startTransition(() => {
if (!isQueueingMessages) {
sendText(message);
setLocalDraft("");
setMessageDraft("");
setMessageBytes(0);
}
});
}}
>
<div className="flex grow gap-2 ">
<label className="w-full">
<Input
autoFocus
@ -62,19 +106,15 @@ export const MessageInput = ({
onChange={handleInputChange}
/>
</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-24 p-2 place-content-end">
{messageBytes}/{maxBytes}
</label>
<Button
type="submit"
variant="default"
>
<Button type="submit" className="dark:bg-white dark:text-slate-900 dark:hover:bg-slate-400 dark:hover:text-white">
<SendIcon size={16} />
</Button>
</div>
</form>
</div>
);
};
};

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

@ -5,138 +5,122 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@components/UI/Tooltip.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useDeviceStore } from "@core/stores/deviceStore.ts";
import { cn } from "@core/utils/cn.ts";
import { Avatar } from "@components/UI/Avatar.tsx";
import { AlertCircle, CheckCircle2, CircleEllipsis } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { ReactNode, useMemo } from "react";
import { MessageState, useMessageStore } from "@core/stores/messageStore/index.ts";
import { Protobuf, Types } from "@meshtastic/js";
import { Message } from "@core/stores/messageStore/types.ts";
// import { MessageActionsMenu } from "@components/PageComponents/Messages/MessageActionsMenu.tsx"; // Uncomment if needed later
import { Message, MessageState } from "@core/services/types.ts";
interface MessageStatusInfo {
interface MessageProps {
lastMsgSameUser: boolean;
message: Message;
}
interface MessageStatus {
state: MessageState;
displayText: string;
icon: LucideIcon;
ariaLabel: string;
iconClassName?: string;
}
const MESSAGE_STATUS_MAP: Record<MessageState, MessageStatusInfo> = {
[MessageState.Ack]: { displayText: "Message delivered", icon: CheckCircle2, ariaLabel: "Message delivered", iconClassName: "text-green-500" },
[MessageState.Waiting]: { displayText: "Waiting for delivery", icon: CircleEllipsis, ariaLabel: "Sending message", iconClassName: "text-slate-400" },
[MessageState.Failed]: { displayText: "Delivery failed", icon: AlertCircle, ariaLabel: "Message delivery failed", iconClassName: "text-red-500 dark:text-red-400" },
const MESSAGE_STATUS: Record<MessageState, MessageStatus> = {
ack: { state: "ack", displayText: "Message delivered", icon: CheckCircle2 },
waiting: { state: "waiting", displayText: "Waiting for delivery", icon: CircleEllipsis },
failed: { state: "failed", displayText: "Delivery failed", icon: AlertCircle },
};
const UNKNOWN_STATUS: MessageStatusInfo = { displayText: "Unknown state", icon: AlertCircle, ariaLabel: "Message status unknown", iconClassName: "text-red-500 dark:text-red-400" };
const getMessageStatus = (state: MessageState): MessageStatus =>
MESSAGE_STATUS[state] || { state: "failed", displayText: "Unknown error", icon: AlertCircle };
const getMessageStatusInfo = (state: MessageState): MessageStatusInfo =>
MESSAGE_STATUS_MAP[state] ?? UNKNOWN_STATUS;
const StatusTooltip = ({ statusInfo, children }: { statusInfo: MessageStatusInfo; children: ReactNode }) => (
<TooltipProvider delayDuration={300}>
const StatusTooltip = ({ status, children }: { status: MessageStatus; children: ReactNode }) => (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs">
{statusInfo.displayText}
<TooltipContent
className="rounded-md bg-slate-800 px-3 py-1.5 text-sm text-white shadow-md animate-in fade-in-0 zoom-in-95"
side="top"
align="center"
sideOffset={5}
>
{status.displayText}
<TooltipArrow className="fill-slate-800" />
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
interface MessageItemProps {
message: Message;
}
export const MessageItem = ({ message }: MessageItemProps) => {
const { getNode } = useDevice();
const { getMyNodeNum } = useMessageStore()
const messageUser: Protobuf.Mesh.NodeInfo | null | undefined = useMemo(() => {
return message.from != null ? getNode(message.from) : null;
}, [getNode, message.from]);
const myNodeNum = useMemo(() => getMyNodeNum(), [getMyNodeNum]);
const { displayName, shortName } = useMemo(() => {
const userIdHex = message.from.toString(16).toUpperCase().padStart(2, '0');
const last4 = userIdHex.slice(-4);
const fallbackName = `Meshtastic ${last4}`
const longName = messageUser?.user?.longName;
const derivedShortName = messageUser?.user?.shortName || fallbackName;
const derivedDisplayName = longName || derivedShortName;
return { displayName: derivedDisplayName, shortName: derivedShortName };
}, [messageUser, message.from]);
const messageStatusInfo = getMessageStatusInfo(message.state);
const StatusIconComponent = messageStatusInfo.icon;
const StatusIcon = ({ status, className, ...otherProps }: { status: MessageStatus; className?: string }) => {
const isFailed = status.state === "failed";
const iconClass = cn("text-slate-500 dark:text-slate-400 w-4 h-4 shrink-0", className);
const Icon = status.icon;
const messageDate = useMemo(() => message.date ? new Date(message.date) : null, [message.date]);
const locale = 'en-US'; // TODO: Make dynamic via props or context
return (
<StatusTooltip status={status}>
<Icon className={iconClass} {...otherProps} color={isFailed ? "red" : "currentColor"} />
</StatusTooltip>
);
};
const formattedTime = useMemo(() =>
messageDate?.toLocaleTimeString(locale, { hour: 'numeric', minute: '2-digit', hour12: true }) ?? '',
[messageDate, locale]);
const getMessageTextStyles = (status: MessageStatus) => {
const isAcknowledged = status.state === "ack";
const isFailed = status.state === "failed";
const fullDateTime = useMemo(() =>
messageDate?.toLocaleString(locale, { dateStyle: 'medium', timeStyle: 'short' }) ?? '',
[messageDate, locale]);
return cn(
"break-words overflow-hidden",
isAcknowledged ? "text-slate-900 dark:text-white" : "text-slate-900 dark:text-slate-400",
isFailed && "text-red-500 dark:text-red-500",
);
};
const isSender = myNodeNum !== undefined && message.from === myNodeNum;
const isOnPrimaryChannel = message.channel === Types.ChannelNumber.Primary; // Use the enum
const shouldShowStatusIcon = isSender && isOnPrimaryChannel;
const TimeDisplay = ({ date, className }: { date: Date; className?: string }) => (
<div className={cn("flex items-center gap-2 shrink-0", className)}>
<span className="text-xs text-slate-500 dark:text-slate-400 font-mono">{date.toLocaleDateString()}</span>
<span className="text-xs text-slate-500 dark:text-slate-400 font-mono">
{date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })}
</span>
</div>
);
export const MessageItem = ({ lastMsgSameUser, message }: MessageProps) => {
const { getDevices } = useDeviceStore();
const messageItemWrapperClass = cn(
"group w-full py-2 relative list-none",
"rounded-md",
"hover:bg-slate-300/15 dark:hover:bg-slate-600/20",
"transition-colors duration-100 ease-in-out",
const isDeviceUser = useMemo(
() =>
getDevices()
.map((device) => device.nodes.get(device.hardware.myNodeNum)?.num)
.includes(message.from),
[getDevices, message.from],
);
const dateTextStyle = "text-xs text-slate-500 dark:text-slate-400";
const messageUser = message?.from
? getDevices().find((device) => device.nodes.has(message.from))?.nodes.get(message.from)
: null;
return (
<li className={messageItemWrapperClass}>
<div className="grid grid-cols-[auto_1fr] gap-x-2">
<Avatar size="sm" text={shortName} className="pt-0.5" />
const messageStatus = getMessageStatus(message.state);
const messageTextClass = getMessageTextStyles(messageStatus);
<div className="flex flex-col gap-0.5 min-w-0">
<div className="flex items-center gap-1.5">
<span className="font-medium text-sm text-slate-900 dark:text-slate-100 truncate mr-1">
{displayName}
</span>
{messageDate && (
<time dateTime={messageDate.toISOString()} className={dateTextStyle}>
<span aria-hidden="true">{formattedTime}</span>
<span className="sr-only">{fullDateTime}</span>
</time>
)}
{shouldShowStatusIcon && (
<StatusTooltip statusInfo={messageStatusInfo}>
<span aria-label={messageStatusInfo.ariaLabel} role="img">
<StatusIconComponent
className={cn("size-4 shrink-0", messageStatusInfo.iconClassName)}
aria-hidden="true"
/>
return (
<div className="flex flex-col w-full px-4 justify-start">
<div className={cn("flex flex-col flex-wrap items-start py-1", messageTextClass, isDeviceUser && "items-end")}>
<div className="flex items-center gap-2 mb-2">
{!lastMsgSameUser && (
<div className="flex place-items-center gap-2 mb-1">
<Avatar text={messageUser?.user?.shortName ?? "UNK"} />
<div className="flex flex-col">
<span className="font-medium text-slate-900 dark:text-white truncate">
{messageUser?.user?.longName}
</span>
</StatusTooltip>
)}
</div>
{message?.message && (
<div className="text-sm text-slate-800 dark:text-slate-200 whitespace-pre-wrap break-words">
{message.message}
</div>
</div>
)}
</div>
<TimeDisplay date={message.date} />
<div className="flex place-items-center gap-2 pb-2">
<div className={cn(isDeviceUser && "pl-11", messageTextClass)}>{message.message}</div>
<StatusIcon status={messageStatus} />
</div>
</div>
{/* Actions Menu Placeholder */}
{/* <div className="absolute top-1 right-1">
<MessageActionsMenu onReply={() => console.log("Reply")} />
</div> */}
</li>
</div>
);
};
};

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

@ -1,33 +1,30 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, Mock } from "vitest";
import { render, screen } from "@testing-library/react";
import { TraceRoute } from "@components/PageComponents/Messages/TraceRoute.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import type { Protobuf } from "@meshtastic/core";
vi.mock("@core/stores/deviceStore");
describe("TraceRoute", () => {
const mockNodes = new Map<number, Protobuf.Mesh.NodeInfo>([
const mockNodes = new Map([
[
1,
{ num: 1, user: { longName: "Node A" } } as Protobuf.Mesh.NodeInfo,
{ num: 1, user: { longName: "Node A" } },
],
[
2,
{ num: 2, user: { longName: "Node B" } } as Protobuf.Mesh.NodeInfo,
{ num: 2, user: { longName: "Node B" } },
],
[
3,
{ num: 3, user: { longName: "Node C" } } as Protobuf.Mesh.NodeInfo,
{ num: 3, user: { longName: "Node C" } },
],
]);
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(useDevice).mockReturnValue({
getNode: (nodeNum: number): Protobuf.Mesh.NodeInfo | undefined => {
return mockNodes.get(nodeNum);
},
(useDevice as Mock).mockReturnValue({
nodes: mockNodes,
});
});
@ -41,16 +38,17 @@ describe("TraceRoute", () => {
/>
);
expect(screen.getAllByText("Source Node")).toHaveLength(1);
expect(screen.getByText("Route to destination:")).toBeInTheDocument();
expect(screen.getByText("Destination Node")).toBeInTheDocument();
expect(screen.getByText("Node A")).toBeInTheDocument();
expect(screen.getByText("Node B")).toBeInTheDocument();
expect(screen.getAllByText(/↓/)).toHaveLength(3);
expect(screen.getAllByText(/↓/)).toHaveLength(3); // startNode + 2 hops
expect(screen.getByText("↓ 10dB")).toBeInTheDocument();
expect(screen.getByText("↓ 20dB")).toBeInTheDocument();
expect(screen.getByText("↓ 30dB")).toBeInTheDocument();
expect(screen.getByText("Source Node")).toBeInTheDocument();
});
it("renders the route back when provided", () => {
@ -66,20 +64,9 @@ describe("TraceRoute", () => {
);
expect(screen.getByText("Route back:")).toBeInTheDocument();
expect(screen.getAllByText("Source Node")).toHaveLength(2);
expect(screen.getAllByText("Destination Node")).toHaveLength(2);
expect(screen.getByText("Node C")).toBeInTheDocument();
expect(screen.getByText("Node A")).toBeInTheDocument();
expect(screen.getByText("↓ 35dB")).toBeInTheDocument();
expect(screen.getByText("↓ 45dB")).toBeInTheDocument();
expect(screen.getByText("↓ 15dB")).toBeInTheDocument();
expect(screen.getByText("↓ 25dB")).toBeInTheDocument();
});
it("renders '??' for missing SNR values", () => {
@ -91,22 +78,18 @@ describe("TraceRoute", () => {
/>
);
expect(screen.getByText("Node A")).toBeInTheDocument();
expect(screen.getAllByText("↓ ??dB")).toHaveLength(2);
expect(screen.getAllByText("↓ ??dB").length).toBeGreaterThan(0);
});
it("renders hop hex if node is not found", () => {
render(
<TraceRoute
from={{ user: { longName: "Source" } } as unknown}
to={{ user: { longName: "Dest" } } as unknown}
from={{ user: { longName: "Source" } } as any}
to={{ user: { longName: "Dest" } } as any}
route={[99]}
snrTowards={[5, 15]}
/>
);
expect(screen.getByText(/^!63$/)).toBeInTheDocument();
expect(screen.getByText("↓ 5dB")).toBeInTheDocument();
expect(screen.getByText("↓ 15dB")).toBeInTheDocument();
expect(screen.getByText(/^!63$/)).toBeInTheDocument(); // 99 in hex
});
});
});

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

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

135
src/components/PageLayout.tsx

@ -1,126 +1,77 @@
import React from 'react';
import { cn } from "@core/utils/cn.ts";
import { type LucideIcon } from "lucide-react";
import { AlignLeftIcon, type LucideIcon } from "lucide-react";
import Footer from "@components/UI/Footer.tsx";
import { Spinner } from "@components/UI/Spinner.tsx";
import { ErrorBoundary } from "react-error-boundary";
import { ErrorPage } from "@components/UI/ErrorPage.tsx";
export interface ActionItem {
key: string;
icon: LucideIcon;
iconClasses?: string;
onClick: () => void;
disabled?: boolean;
isLoading?: boolean;
ariaLabel?: string;
}
export interface PageLayoutProps {
label: string;
actions?: ActionItem[];
children: React.ReactNode;
leftBar?: React.ReactNode;
rightBar?: React.ReactNode;
noPadding?: boolean;
leftBarClassName?: string;
rightBarClassName?: string;
topBarClassName?: string;
contentClassName?: string;
children: React.ReactNode;
className?: string;
actions?: {
icon: LucideIcon;
iconClasses?: string;
onClick: () => void;
disabled?: boolean;
isLoading?: boolean;
}[];
}
export const PageLayout = ({
label,
noPadding,
actions,
className,
children,
leftBar,
rightBar,
noPadding,
leftBarClassName,
rightBarClassName,
topBarClassName,
contentClassName
}: PageLayoutProps) => {
return (
<ErrorBoundary FallbackComponent={ErrorPage}>
<div className="flex flex-1 bg-background text-foreground overflow-hidden">
{/* Left Sidebar */}
{leftBar && (
<aside
className={cn(
"px-2 pr-0 shrink-0 border-r-[0.5px] border-slate-300 dark:border-slate-700 ",
leftBarClassName
)}
>
{leftBar}
</aside>
)}
<div className="flex flex-1 flex-col min-w-0">
{/* Header */}
<header
className={cn(
"flex h-14 shrink-0 mt-2 p-2 items-center border-b border-slate-300 dark:border-slate-700",
topBarClassName
)}
<div className="relative flex h-full w-full flex-col">
<div className="flex h-14 shrink-0 border-b-[0.5px] border-slate-300 dark:border-slate-700 md:h-16 md:px-4">
<button
type="button"
className="pl-4 transition-all hover:text-accent md:hidden"
>
{/* Header Content */}
<div className="flex flex-1 items-center justify-between min-w-0">
<span className="text-lg font-medium text-foreground truncate px-2">
{label}
</span>
<div className="flex items-center space-x-3 md:space-x-4 shrink-0">
<AlignLeftIcon />
</button>
<div className="flex flex-1 items-center justify-between px-4 md:px-0">
<div className="flex w-full items-center">
<span className="w-full text-lg font-medium">{label}</span>
<div className="flex justify-end space-x-4">
{actions?.map((action) => (
<button
key={action.key}
key={action.icon.displayName}
type="button"
disabled={action.disabled || action.isLoading}
className="text-foreground transition-colors hover:text-accent disabled:opacity-50 disabled:cursor-not-allowed"
disabled={action?.disabled}
className="transition-all hover:text-accent"
onClick={action.onClick}
aria-label={action.ariaLabel || `Action ${action.key}`}
aria-disabled={action.disabled}
aria-busy={action.isLoading}
>
<div className="mr-6">
{action.isLoading ? (
<Spinner size="md" />
) : (
<action.icon
className={cn("h-5 w-5", action.iconClasses)}
/>
)}
</div>
{action?.isLoading ? <Spinner /> : (
<action.icon
className={action.iconClasses}
aria-disabled={action.disabled}
/>
)}
</button>
))}
</div>
</div>
</header>
<main
className={cn(
"flex-1 flex flex-col",
"overflow-hidden",
!noPadding && "px-2",
contentClassName
)}
>
{children}
</main>
</div>
</div>
<div
className={cn(
"flex h-full w-full flex-col overflow-y-auto",
!noPadding && "pl-3 pr-3 ",
className
)}
>
{children}
<Footer />
</div>
{/* Right Sidebar */}
{rightBar && (
<aside
className={cn(
"w-48 lg:w-[270px] shrink-0 border-l border-slate-300 dark:border-slate-700 px-2 overflow-hidden",
rightBarClassName
)}
>
{rightBar}
</aside>
)}
</div>
</ErrorBoundary>
);
};
};

295
src/components/Sidebar.tsx

@ -1,241 +1,148 @@
import React from "react";
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
import { SidebarButton } from "@components/UI/Sidebar/sidebarButton.tsx";
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import type { Page } from "@core/stores/deviceStore.ts";
import { Spinner } from "@components/UI/Spinner.tsx";
import { Avatar } from "@components/UI/Avatar.tsx";
import {
CircleChevronLeft,
BatteryMediumIcon,
CpuIcon,
EditIcon,
LayersIcon,
type LucideIcon,
MapIcon,
MessageSquareIcon,
PenLine,
SearchIcon,
SettingsIcon,
SidebarCloseIcon,
SidebarOpenIcon,
UsersIcon,
ZapIcon,
} from "lucide-react";
import { cn } from "@core/utils/cn.ts";
import { useSidebar } from "@core/stores/sidebarStore.tsx";
import ThemeSwitcher from "@components/ThemeSwitcher.tsx";
import { useAppStore } from "@core/stores/appStore.ts";
import BatteryStatus from "@components/BatteryStatus.tsx";
import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.tsx";
import { useState } from "react";
export interface SidebarProps {
children?: React.ReactNode;
}
interface NavLink {
name: string;
icon: LucideIcon;
page: Page;
}
const CollapseToggleButton = () => {
const { isCollapsed, toggleSidebar } = useSidebar();
const buttonLabel = isCollapsed ? "Open sidebar" : "Close sidebar";
return (
<button
type="button"
aria-label={buttonLabel}
onClick={toggleSidebar}
className={cn(
'absolute top-20 right-0 z-10 p-0.5 rounded-full transform translate-x-1/2',
'transition-colors duration-300 ease-in-out',
'border border-slate-300 dark:border-slate-200',
'text-slate-500 dark:text-slate-200 hover:text-slate-400 dark:hover:text-slate-400',
'focus:outline-none focus:ring-2 focus:ring-accent transition-transform'
)}
>
<CircleChevronLeft
size={24}
className={cn(
'transition-transform duration-300 ease-in-out',
isCollapsed && 'rotate-180'
)}
/>
</button>
);
}
export const Sidebar = ({ children }: SidebarProps) => {
const { hardware, getNode, getNodesLength, metadata, activePage, setActivePage, setDialogOpen } = useDevice();
const { setCommandPaletteOpen } = useAppStore();
const myNode = getNode(hardware.myNodeNum);
const { isCollapsed } = useSidebar();
const { hardware, nodes, metadata } = useDevice();
const myNode = nodes.get(hardware.myNodeNum);
const myMetadata = metadata.get(0);
const { activePage, setActivePage, setDialogOpen } = useDevice();
const [showSidebar, setShowSidebar] = useState<boolean>(true);
interface NavLink {
name: string;
icon: LucideIcon;
page: Page;
}
const pages: NavLink[] = [
{ name: "Messages", icon: MessageSquareIcon, page: "messages" },
{ name: "Map", icon: MapIcon, page: "map" },
{ name: "Config", icon: SettingsIcon, page: "config" },
{ name: "Channels", icon: LayersIcon, page: "channels" },
{
name: `Nodes (${Math.max(getNodesLength() - 1, 0)})`,
name: "Messages",
icon: MessageSquareIcon,
page: "messages",
},
{
name: "Map",
icon: MapIcon,
page: "map",
},
{
name: "Config",
icon: SettingsIcon,
page: "config",
},
{
name: "Channels",
icon: LayersIcon,
page: "channels",
},
{
name: `Nodes (${nodes.size - 1})`,
icon: UsersIcon,
page: "nodes",
},
];
return (
<div
className={cn(
'relative border-slate-300 dark:border-slate-700',
'transition-all duration-300 ease-in-out flex-shrink-0',
isCollapsed ? 'w-24' : 'w-46 lg:w-64'
)}
>
<CollapseToggleButton />
<div
className={cn(
'h-14 flex mt-2 gap-2 items-center flex-shrink-0 transition-all duration-300 ease-in-out',
'border-b-[0.5px] border-slate-300 dark:border-slate-700',
isCollapsed && 'justify-center px-0'
)}
>
<img
src="Logo.svg"
alt="Meshtastic Logo"
className="size-10 flex-shrink-0 rounded-xl"
/>
<h2
className={cn(
'text-xl font-semibold text-gray-800 dark:text-gray-100 whitespace-nowrap',
'transition-all duration-300 ease-in-out',
isCollapsed
? 'opacity-0 max-w-0 invisible ml-0'
: 'opacity-100 max-w-xs visible ml-2'
)}
>
Meshtastic
</h2>
</div>
<SidebarSection label="Navigation" className="mt-4 px-0">
{pages.map((link) => (
<SidebarButton
key={link.name}
label={link.name}
Icon={link.icon}
onClick={() => {
if (myNode !== undefined) {
setActivePage(link.page);
}
}}
active={link.page === activePage}
disabled={myNode === undefined}
/>
))}
</SidebarSection>
<div className={cn(
'flex-1 min-h-0',
isCollapsed && 'overflow-hidden'
)}
>
{children}
</div>
<div className="pt-4 border-t-[0.5px] bg-background-primary border-slate-300 dark:border-slate-700 flex-shrink-0">
return showSidebar
? (
<div className="min-w-[280px] max-w-min flex-col overflow-y-auto border-r-[0.5px] bg-background-primary border-slate-300 dark:border-slate-400">
{myNode === undefined ? (
<div className="flex flex-col items-center justify-center py-6">
<div className="flex flex-col items-center justify-center px-8 py-6">
<Spinner />
<Subtle
className={cn(
'mt-4 transition-opacity duration-300',
isCollapsed ? 'opacity-0 invisible' : 'opacity-100 visible'
)}
>
Loading...
</Subtle>
<Subtle className="mt-2">Loading device info...</Subtle>
</div>
) : (
<>
<div
className={cn(
'flex place-items-center gap-2',
isCollapsed && 'justify-center'
)}
>
<Avatar
text={myNode.user?.shortName ?? myNode.num.toString()}
className={cn("flex-shrink-0 ml-2",
isCollapsed && "ml-0",
)}
size="sm"
/>
<p
className={cn(
'max-w-[20ch] text-wrap text-sm font-medium',
'transition-all duration-300 ease-in-out overflow-hidden',
isCollapsed
? 'opacity-0 max-w-0 invisible'
: 'opacity-100 max-w-full visible'
)}
>
{myNode.user?.longName}
</p>
</div>
<div
className={cn(
'flex flex-col gap-0.5 ml-2 mt-2',
'transition-all duration-300 ease-in-out',
isCollapsed
? 'opacity-0 max-w-0 h-0 invisible'
: 'opacity-100 max-w-xs h-auto visible'
)}
>
<div className="inline-flex gap-2">
<BatteryStatus deviceMetrics={myNode.deviceMetrics} />
</div>
<div className="inline-flex gap-2">
<ZapIcon size={18} className="text-gray-500 dark:text-gray-400 w-4 flex-shrink-0" />
<Subtle>{myNode.deviceMetrics?.voltage?.toPrecision(3) ?? "UNK"} volts</Subtle>
<div className="flex justify-between px-8 pt-6">
<div>
<span className="text-lg font-medium">
{myNode.user?.shortName ?? "UNK"}
</span>
<Subtle>{myNode.user?.longName ?? "UNK"}</Subtle>
</div>
<div className="inline-flex gap-2">
<CpuIcon size={18} className="text-gray-500 dark:text-gray-400 w-4 flex-shrink-0" />
<Subtle>v{myMetadata?.firmwareVersion ?? "UNK"}</Subtle>
</div>
</div>
<div
className={cn(
'flex items-center flex-shrink-0 ml-2',
'transition-all duration-300 ease-in-out',
isCollapsed
? 'opacity-0 max-w-0 invisible pointer-events-none'
: 'opacity-100 max-w-xs visible'
)}
>
<button
type="button"
aria-label="Edit device name"
className="p-1 rounded transition-colors hover:text-accent"
className="transition-all hover:text-accent"
onClick={() => setDialogOpen("deviceName", true)}
>
<PenLine size={22} />
<EditIcon size={16} />
</button>
<ThemeSwitcher />
<button
type="button"
className="transition-all hover:text-accent"
onClick={() => setCommandPaletteOpen(true)}
>
<SearchIcon />
<button type="button" onClick={() => setShowSidebar(false)}>
<SidebarCloseIcon size={24} />
</button>
</div>
<div className="px-8 pb-6">
<div className="flex items-center">
<BatteryMediumIcon size={24} viewBox="0 0 28 24" />
<Subtle>
{myNode.deviceMetrics?.batteryLevel
? myNode.deviceMetrics.batteryLevel > 100
? "Charging"
: `${myNode.deviceMetrics.batteryLevel}%`
: "UNK"}
</Subtle>
</div>
<div className="flex items-center">
<ZapIcon size={24} viewBox="0 0 36 24" />
<Subtle>
{myNode.deviceMetrics?.voltage?.toPrecision(3) ?? "UNK"} volts
</Subtle>
</div>
<div className="flex items-center">
<CpuIcon size={24} viewBox="0 0 36 24" />
<Subtle>v{myMetadata?.firmwareVersion ?? "UNK"}</Subtle>
</div>
</div>
</>
)}
<SidebarSection label="Navigation">
{pages.map((link) => (
<SidebarButton
key={link.name}
label={link.name}
Icon={link.icon}
onClick={() => {
if (myNode !== undefined) {
setActivePage(link.page);
}
}}
active={link.page === activePage}
disabled={myNode === undefined}
/>
))}
</SidebarSection>
{children}
</div>
)
: (
<div className="px-1 pt-8 border-r-[0.5px] border-slate-700">
<button type="button" onClick={() => setShowSidebar(true)}>
<SidebarOpenIcon size={24} />
</button>
</div>
</div>
);
};
);
};

12
src/components/ThemeSwitcher.tsx

@ -12,9 +12,9 @@ export default function ThemeSwitcher({
const { theme, preference, setPreference } = useTheme();
const themeIcons = {
light: <Sun className="size-6" />,
dark: <Moon className="size-6" />,
system: <Monitor className="size-6" />,
light: <Sun className="size-5" />,
dark: <Moon className="size-5" />,
system: <Monitor className="size-5" />,
};
const toggleTheme = () => {
@ -30,15 +30,15 @@ export default function ThemeSwitcher({
<button
type="button"
className={cn(
"transition-all duration-300 scale-100 cursor-pointer m-3 p-2 focus:*:data-label:opacity-100",
"transition-all duration-300 scale-100 cursor-pointer m-6 p-2 focus:*:data-label:opacity-100",
className,
)}
onClick={toggleTheme}
aria-description="Change current theme"
aria-description={"Change current theme"}
>
<span
data-label
className="transition-all block absolute w-full mb-auto mt-auto ml-0 mr-0 text-xs left-0 -top-3 opacity-0 rounded-lg"
className="transition-all block absolute w-full mb-auto mt-auto ml-0 mr-0 text-xs left-0 -top-5 opacity-0 rounded-lg"
>
{firstCharOfPreference.toLocaleUpperCase() +
(restOfPreference ?? []).join("")}

71
src/components/UI/Avatar.tsx

@ -1,5 +1,6 @@
import { cn } from "@core/utils/cn.ts";
import { LockKeyholeOpenIcon } from 'lucide-react';
import type React from "react";
type RGBColor = {
r: number;
@ -9,12 +10,13 @@ type RGBColor = {
};
interface AvatarProps {
text: string | number;
text: string;
size?: "sm" | "lg";
className?: string;
showError?: boolean;
}
// biome-ignore lint/complexity/noStaticOnlyClass: stop being annoying Biome
class ColorUtils {
static hexToRgb(hex: number): RGBColor {
return {
@ -40,46 +42,46 @@ class ColorUtils {
}
}
const getColorFromText = (text: string): RGBColor => {
if (!text) {
return { r: 0, g: 0, b: 0, a: 255 };
}
let hash = 0;
for (let i = 0; i < text?.length; i++) {
hash = text.charCodeAt(i) + ((hash << 5) - hash);
hash |= 0;
}
return {
r: (hash & 0xff0000) >> 16,
g: (hash & 0x00ff00) >> 8,
b: hash & 0x0000ff,
a: 255,
};
};
export const Avatar = ({
export const Avatar: React.FC<AvatarProps> = ({
text,
size = "sm",
showError = false,
className,
}: AvatarProps) => {
}) => {
const sizes = {
sm: "size-10 text-xs font-light",
sm: "size-11 text-xs",
lg: "size-16 text-lg",
};
const safeText = text?.toString().toUpperCase();
const bgColor = getColorFromText(safeText);
// Pick a color based on the text provided to function
const getColorFromText = (text: string): RGBColor => {
let hash = 0;
for (let i = 0; i < text.length; i++) {
hash = text.charCodeAt(i) + ((hash << 5) - hash);
}
return {
r: (hash & 0xff0000) >> 16,
g: (hash & 0x00ff00) >> 8,
b: hash & 0x0000ff,
a: 255,
};
};
const bgColor = getColorFromText(text ?? "UNK");
const isLight = ColorUtils.isLight(bgColor);
const textColor = isLight ? "#000000" : "#FFFFFF";
const initials = safeText?.slice(0, 4) ?? "UNK";
const initials = text?.toUpperCase().slice(0, 4) ?? "UNK";
return (
<div
className={cn(
`relative flex items-center justify-center rounded-full font-semibold
`,
`flex
relative
rounded-full
items-center
justify-center
font-semibold`,
sizes[size],
className,
)}
@ -88,17 +90,8 @@ export const Avatar = ({
color: textColor,
}}
>
{showError ? (
<LockKeyholeOpenIcon
className="absolute bottom-0 right-0 z-10 size-4 text-red-500 stroke-3"
aria-hidden="true"
/>
) : null}
<p
className="p-1"
>
{initials}
</p>
{showError ? <LockKeyholeOpenIcon className="size-4 absolute bottom-0 right-0 z-10 text-red-500 stroke-3" /> : null}
<p className="p-1">{initials}</p>
</div>
);
};
};

46
src/components/UI/Button.tsx

@ -1,20 +1,21 @@
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "@core/utils/cn.ts";
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:opacity-50 dark:focus:ring-slate-400 disabled:cursor-not-allowed dark:focus:ring-offset-slate-900 cursor-pointer",
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus:outline-hidden focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:opacity-50 dark:focus:ring-slate-400 disabled:pointer-events-none dark:focus:ring-offset-slate-900 cursor-pointer",
{
variants: {
variant: {
default:
"bg-slate-900 text-white dark:bg-slate-50 hover:dark:bg-slate-200 dark:text-slate-900 hover:bg-slate-500",
"bg-slate-900 text-white dark:bg-slate-900 hover:dark:bg-slate-700 dark:text-slate-100 hover:bg-slate-800 ",
destructive:
"bg-red-500 text-white hover:bg-red-600 dark:hover:bg-red-600",
success:
"bg-green-500 text-white hover:bg-green-600 dark:hover:bg-green-600",
outline:
"bg-transparent border border-slate-400 hover:text-slate-400 dark:hover:text-slate-300 dark:border-slate-400 dark:text-slate-100 ",
"bg-transparent border border-slate-400 hover:bg-slate-100 dark:border-slate-400 dark:text-slate-500",
subtle:
"bg-slate-100 text-slate-700 hover:bg-slate-200 dark:bg-slate-500 dark:text-white dark:hover:bg-slate-400",
ghost:
@ -26,7 +27,6 @@ const buttonVariants = cva(
default: "h-10 py-2 px-4",
sm: "h-9 px-2 rounded-md",
lg: "h-11 px-8 rounded-md",
icon: "h-10 w-10",
},
},
defaultVariants: {
@ -39,50 +39,26 @@ const buttonVariants = cva(
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
icon?: React.ReactNode;
iconAlignment?: "left" | "right";
}
extends
React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> { }
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{
className,
variant,
size,
disabled,
icon,
iconAlignment = "left",
children,
...props
},
ref,
) => {
({ className, variant, size, disabled, ...props }, ref) => {
return (
<button
type="button"
className={cn(
buttonVariants({ variant, size, className }),
{ "cursor-not-allowed": disabled },
"inline-flex items-center"
{ "cursor-not-allowed": disabled }
)}
ref={ref}
disabled={disabled}
{...props}
>
{icon && iconAlignment === "left" && (
<span className={cn({ "mr-2": !!children })}>{icon}</span>
)}
{children}
{icon && iconAlignment === "right" && (
<span className={cn({ "ml-2": !!children })}>{icon}</span>
)}
</button>
/>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };
export { Button, buttonVariants };

9
src/components/UI/Checkbox/index.tsx

@ -1,4 +1,4 @@
import { useState, useEffect, useId } from "react";
import { useState, useId } from "react";
import { Check } from "lucide-react";
import { Label } from "@components/UI/Label.tsx";
import { cn } from "@core/utils/cn.ts";
@ -32,11 +32,6 @@ export function Checkbox({
const [isChecked, setIsChecked] = useState(checked || false);
// Make sure setIsChecked state updates with checked
useEffect(() => {
setIsChecked(checked || false);
}, [checked]);
const handleToggle = () => {
if (disabled) return;
@ -71,7 +66,7 @@ export function Checkbox({
>
{isChecked && (
<div className="animate-fade-in scale-100 opacity-100">
<Check className="w-4 h-4 text-slate-900 dark:text-slate-200" />
<Check className="w-4 h-4 text-slate-900 dark:text-slate-900" />
</div>
)}
</div>

6
src/components/UI/Command.tsx

@ -41,7 +41,7 @@ const CommandInput = React.forwardRef<
className="flex items-center border-b border-b-slate-100 px-4 dark:border-b-slate-700"
cmdk-input-wrapper=""
>
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50 text-white" />
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
@ -88,7 +88,7 @@ const CommandGroup = React.forwardRef<
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden py-3 px-2 text-slate-700 dark:text-slate-200 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:pb-1.5 [&_[cmdk-group-heading]]:text-sm [&_[cmdk-group-heading]]:font-semibold [&_[cmdk-group-heading]]:text-slate-900 dark:[&_[cmdk-group-heading]]:text-slate-300",
"overflow-hidden py-3 px-2 text-slate-700 dark:text-slate-400 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:pb-1.5 [&_[cmdk-group-heading]]:text-sm [&_[cmdk-group-heading]]:font-semibold [&_[cmdk-group-heading]]:text-slate-900 dark:[&_[cmdk-group-heading]]:text-slate-300",
className,
)}
{...props}
@ -103,7 +103,7 @@ const CommandSeparator = React.forwardRef<
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-slate-100 dark:bg-slate-200", className)}
className={cn("-mx-1 h-px bg-slate-100 dark:bg-slate-700", className)}
{...props}
/>
));

9
src/components/UI/Dialog.tsx

@ -44,7 +44,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed z-50 grid w-full bg-white dark:bg-slate-800 dark:text-slate-200 text-slate-900 max-w-[512px] max-h-[100vh] overflow-y-auto scale-100 gap-4 p-6 opacity-100 animate-in fade-in-90 slide-in-from-bottom-10 sm:max-w-lg sm:rounded-lg sm:zoom-in-90 sm:slide-in-from-bottom-0",
"fixed z-50 grid w-full bg-white max-w-[512px] max-h-[100vh] overflow-y-auto scale-100 gap-4 p-6 opacity-100 animate-in fade-in-90 slide-in-from-bottom-10 sm:max-w-lg sm:rounded-lg sm:zoom-in-90 sm:slide-in-from-bottom-0 dark:text-slate-900",
className,
)}
{...props}
@ -60,8 +60,7 @@ const DialogClose = ({
...props
}: DialogPrimitive.DialogCloseProps & React.RefAttributes<HTMLButtonElement> & { className?: string }) => (
<DialogPrimitive.Close
aria-label="Close"
data-testid="dialog-close-button"
name="close"
className={cn(
"absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-slate-100 dark:focus:ring-slate-400 dark:focus:ring-offset-slate-900",
className,
@ -107,7 +106,7 @@ const DialogTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
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", className)}
{...props}
/>
));
@ -119,7 +118,7 @@ const DialogDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-slate-800 dark:text-slate-200", className)}
className={cn("text-sm text-slate-700", className)}
{...props}
/>
));

2
src/components/UI/Footer.tsx

@ -7,7 +7,7 @@ type FooterProps = {
const Footer = ({ className, ...props }: FooterProps) => {
return (
<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 p-2", className)}
{...props}
>
<p>

37
src/components/UI/Generator.tsx

@ -9,13 +9,7 @@ import {
SelectTrigger,
SelectValue,
} from "@components/UI/Select.tsx";
export interface ActionButton {
text: string;
onClick: React.MouseEventHandler<HTMLButtonElement>;
variant: ButtonVariant;
className?: string;
}[]
import type { LucideIcon } from "lucide-react";
export interface GeneratorProps extends React.BaseHTMLAttributes<HTMLElement> {
type: "text" | "password";
@ -23,12 +17,19 @@ export interface GeneratorProps extends React.BaseHTMLAttributes<HTMLElement> {
value: string;
id: string;
variant: "default" | "invalid";
actionButtons: ActionButton[];
actionButtons: {
text: string;
onClick: React.MouseEventHandler<HTMLButtonElement>;
variant: ButtonVariant;
className?: string;
}[];
bits?: { text: string; value: string; key: string }[];
selectChange: (event: string) => void;
inputChange: (event: React.ChangeEventHandler<HTMLInputElement> | undefined) => void;
showPasswordToggle?: boolean;
showCopyButton?: boolean;
inputChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
action?: {
icon: LucideIcon;
onClick: () => void;
};
disabled?: boolean;
}
@ -49,9 +50,8 @@ const Generator =
],
selectChange,
inputChange,
action,
disabled,
showPasswordToggle,
showCopyButton,
...props
}: GeneratorProps
) => {
@ -78,28 +78,27 @@ const Generator =
variant={variant}
value={value}
onChange={inputChange}
action={action}
disabled={disabled}
ref={inputRef}
showCopyButton={showCopyButton}
showPasswordToggle={showPasswordToggle}
/>
<Select
value={devicePSKBitCount?.toString()}
onValueChange={(e) => selectChange(e)}
disabled={disabled}
>
<SelectTrigger className="w-36 ml-2">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent className="w-36">
<SelectContent>
{bits.map(({ text, value, key }) => (
<SelectItem key={key} value={value} className="w-36">
<SelectItem key={key} value={value}>
{text}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex ml-2 space-x-2">
<div className="flex ml-4 space-x-4">
{actionButtons?.map(({ text, onClick, variant, className }) => (
<Button
key={text}

192
src/components/UI/Input.tsx

@ -1,185 +1,73 @@
import * as React from "react";
import { cn } from "@core/utils/cn.ts";
import { cva, type VariantProps } from "class-variance-authority";
import { Check, Copy, Eye, EyeOff, X, type LucideIcon } from "lucide-react";
import { useCopyToClipboard } from "@core/hooks/useCopyToClipboard.ts";
import { usePasswordVisibilityToggle } from "@core/hooks/usePasswordVisibilityToggle.ts";
import type { LucideIcon } from "lucide-react";
const inputVariants = cva(
"flex h-10 w-full rounded-md border border-slate-300 bg-transparent py-2 px-3 text-sm placeholder:text-slate-400 focus:outline-none focus:ring-1 focus:ring-slate-400 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-500 dark:bg-transparet dark:text-slate-100 dark:focus:ring-slate-400 dark:focus:ring-offset-slate-600",
"flex h-10 w-full items-center justify-between rounded-md border border-slate-300 bg-transparent py-2 px-3 text-sm placeholder:text-slate-400 focus:outline-hidden focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:text-slate-50 dark:focus:ring-slate-400 dark:focus:ring-offset-slate-900 dark:open-dialog:text-slate-900",
{
variants: {
variant: {
default: "border-slate-300 dark:border-slate-500",
invalid:
"border-red-500 dark:border-red-500 focus:ring-red-500 dark:focus:ring-red-500",
default: "border-slate-300 dark:border-slate-700",
invalid: "border-red-500 dark:border-red-500",
},
},
defaultVariants: {
variant: "default",
},
}
},
);
type InputActionType = {
id: string;
icon: LucideIcon;
onClick: (e: React.MouseEvent<HTMLButtonElement>) => void;
ariaLabel: string;
tooltip?: string
condition?: boolean;
};
export interface InputProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "prefix" | "suffix">,
extends
React.InputHTMLAttributes<HTMLInputElement>,
VariantProps<typeof inputVariants> {
prefix?: React.ReactNode;
suffix?: React.ReactNode;
showPasswordToggle?: boolean;
showCopyButton?: boolean;
showClearButton?: boolean;
containerClassName?: string;
prefix?: string;
suffix?: string;
action?: {
icon: LucideIcon;
onClick: () => void;
};
}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
(
{
className,
containerClassName,
variant,
type = "text",
prefix,
suffix,
showPasswordToggle,
showCopyButton,
showClearButton,
value,
onChange,
...props
},
ref
) => {
const { isVisible, toggleVisibility } = usePasswordVisibilityToggle();
const { copy, isCopied } = useCopyToClipboard({ timeout: 1500 });
const potentialActions: InputActionType[] = [
{
id: "clear-input",
icon: X,
onClick: (e) => {
e.stopPropagation();
if (onChange) {
const event = {
target: { value: "" },
currentTarget: { value: "" },
} as React.ChangeEvent<HTMLInputElement>;
onChange(event);
}
if (ref && typeof ref !== "function" && ref.current) {
ref.current.focus();
}
},
ariaLabel: "Clear input",
tooltip: "Clear input",
condition: !!showClearButton && !!value,
},
{
id: "toggle-visibility",
icon: isVisible ? EyeOff : Eye,
onClick: (e) => {
e.stopPropagation();
toggleVisibility();
},
ariaLabel: isVisible ? "Hide password" : "Show password",
tooltip: isVisible ? "Hide password" : "Show password",
condition: !!showPasswordToggle && type === "password",
},
{
id: "copy-value",
icon: isCopied ? Check : Copy,
onClick: (e) => {
e.stopPropagation();
if (value !== undefined && value !== null) {
copy(String(value));
}
},
ariaLabel: isCopied ? "Copied!" : "Copy to clipboard",
tooltip: isCopied ? "Copied!" : "Copy to clipboard",
condition: !!showCopyButton,
},
];
const actions = potentialActions.filter(action => action.condition);
const inputType =
showPasswordToggle ? (isVisible ? "text" : "password") : type;
const hasPrefix = !!prefix;
const hasSuffix = !!suffix;
const hasActions = actions.length > 0;
const inputClassName = cn(
inputVariants({ variant }),
hasActions && !hasSuffix && "pr-10",
hasPrefix && "rounded-l-none",
className
);
({ className, value, variant, prefix, suffix, action, ...props }, ref) => {
return (
<div className={cn("relative flex w-full items-stretch", containerClassName)}>
<div className="relative w-full">
{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">
<label className="inline-flex items-center rounded-l-md bg-slate-100/80 px-3 font-mono text-sm text-slate-600">
{prefix}
</span>
</label>
)}
<input
type={inputType === "password" && isVisible ? "text" : inputType}
className={inputClassName}
ref={ref}
className={cn(
action && "pr-8",
inputVariants({ variant }),
className,
)}
value={value}
onChange={onChange}
ref={ref}
{...props}
/>
<div className="absolute right-0 top-0 flex h-full items-stretch">
{suffix && (
<span className={cn(
"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}
</span>
)}
{hasActions && (
<div className={cn(
"flex items-center divide-x divide-slate-300 border border-l-0 border-slate-300 dark:divide-slate-700 dark:border-slate-700",
!hasSuffix && "rounded-r-md",
"bg-white dark:bg-slate-800"
)}>
{actions.map((action) => (
<button
key={action.id}
type="button"
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",
action.id === 'copy-value' && isCopied && "text-green-600 dark:text-green-500"
)}
onClick={action.onClick}
aria-label={action.ariaLabel}
title={action.tooltip || action.ariaLabel}
>
<action.icon size={18} aria-hidden="true" />
</button>
))}
</div>
)}
</div>
{suffix && (
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-9 font-mono text-slate-500 dark:text-slate-900">
<span className="text-slate-100/40 sm:text-sm">{suffix}</span>
</div>
)}
{action && (
<button
type="button"
className="absolute inset-y-0 right-0 flex items-center pr-3 text-slate-500 hover:text-slate-400 focus:outline-hidden "
onClick={action.onClick}
>
<action.icon size={20} />
</button>
)}
</div>
);
}
},
);
Input.displayName = "Input";
export { Input, inputVariants };
export { Input, inputVariants };

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

@ -1,81 +0,0 @@
import React from "react";
import { Button } from "@components/UI/Button.tsx";
import type { LucideIcon } from "lucide-react";
import { cn } from "@core/utils/cn.ts";
import { useSidebar } from "@core/stores/sidebarStore.tsx";
export interface SidebarButtonProps {
label: string;
count?: number;
active?: boolean;
Icon?: LucideIcon;
children?: React.ReactNode;
onClick?: () => void;
disabled?: boolean;
preventCollapse?: boolean;
}
export const SidebarButton = ({
label,
active,
Icon,
count,
children,
onClick,
disabled = false,
preventCollapse = false,
}: SidebarButtonProps) => {
const { isCollapsed: isSidebarCollapsed } = useSidebar();
const isButtonCollapsed = isSidebarCollapsed && !preventCollapse;
return (
<Button
onClick={onClick}
variant={active ? "subtle" : "ghost"}
size="sm"
className={cn(
"flex w-full items-center text-wrap",
isButtonCollapsed
? 'justify-center gap-0 px-2 h-9'
: 'justify-start gap-2 min-h-9'
)}
disabled={disabled}
>
{Icon && (
<Icon
size={isButtonCollapsed ? 20 : 18}
className="flex-shrink-0"
/>
)}
{children}
<span
className={cn(
'flex flex-wrap justify-start text-left text-wrap break-all',
'min-w-0',
'px-1',
'transition-all duration-300 ease-in-out',
isButtonCollapsed
? 'opacity-0 max-w-0 invisible w-0 overflow-hidden'
: 'opacity-100 max-w-full visible flex-1 whitespace-normal'
)}
>
{label}
</span>
{!isButtonCollapsed && !active && count && count > 0 && (
<div
className={cn(
"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",
"transition-opacity duration-300 ease-in-out",
isButtonCollapsed ? 'opacity-0 invisible' : 'opacity-100 visible'
)}
>
{count}
</div>
)}
</Button>
);
};

47
src/components/UI/Sidebar/SidebarSection.tsx

@ -1,42 +1,19 @@
import React from "react";
import { cn } from "@core/utils/cn.ts";
import { Heading } from "@components/UI/Typography/Heading.tsx";
import { useSidebar } from "@core/stores/sidebarStore.tsx";
import { Heading } from "../Typography/Heading.tsx";
interface SidebarSectionProps {
export interface SidebarSectionProps {
label: string;
subheader?: string;
children: React.ReactNode;
className?: string;
}
export const SidebarSection = ({
label,
label: title,
children,
className,
}: SidebarSectionProps) => {
const { isCollapsed } = useSidebar();
return (
<div className={cn(
"py-2",
isCollapsed ? 'px-0' : 'px-4',
className,
)}>
<Heading as="h3" className={cn(
'mb-2',
'uppercase tracking-wider text-md',
'transition-all duration-300 ease-in-out',
'whitespace-nowrap overflow-hidden',
isCollapsed
? 'opacity-0 max-w-0 h-0 invisible px-0 mb-0'
: 'opacity-100 max-w-xs h-auto visible px-1 mb-1'
)}>
{label}
</Heading>
<div className="space-y-0.5">
{children}
</div>
</div>
);
};
}: SidebarSectionProps) => (
<div className="px-4 py-2">
<Heading as="h4" className="mb-3 ml-2">
{title}
</Heading>
<div className="space-y-1">{children}</div>
</div>
);

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

@ -1,81 +1,32 @@
import React from "react";
import { Button } from "@components/UI/Button.tsx";
import type { LucideIcon } from "lucide-react";
import { cn } from "@core/utils/cn.ts";
import { useSidebar } from "@core/stores/sidebarStore.tsx";
export interface SidebarButtonProps {
label: string;
count?: number;
active?: boolean;
Icon?: LucideIcon;
children?: React.ReactNode;
element?;
onClick?: () => void;
disabled?: boolean;
preventCollapse?: boolean;
}
export const SidebarButton = ({
label,
active,
Icon,
count,
children,
element,
onClick,
disabled = false,
preventCollapse = false,
}: SidebarButtonProps) => {
const { isCollapsed: isSidebarCollapsed } = useSidebar();
const isButtonCollapsed = isSidebarCollapsed && !preventCollapse;
return (
<Button
onClick={onClick}
variant={active ? "subtle" : "ghost"}
size="sm"
className={cn(
"flex w-full items-center text-wrap",
isButtonCollapsed
? 'justify-center gap-0 px-2 h-9'
: 'justify-start gap-2 min-h-9'
)}
disabled={disabled}
>
{Icon && (
<Icon
size={isButtonCollapsed ? 20 : 18}
className="flex-shrink-0"
/>
)}
{children}
<span
className={cn(
'flex flex-wrap justify-start text-left text-wrap break-all',
'min-w-0',
'px-1',
'transition-all duration-300 ease-in-out',
isButtonCollapsed
? 'opacity-0 max-w-0 invisible w-0 overflow-hidden'
: 'opacity-100 max-w-full visible flex-1 whitespace-normal'
)}
>
{label}
</span>
{!isButtonCollapsed && !active && count && count > 0 && (
<div
className={cn(
"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",
"transition-opacity duration-300 ease-in-out",
isButtonCollapsed ? 'opacity-0 invisible' : 'opacity-100 visible'
)}
>
{count}
</div>
)}
</Button>
);
};
}: SidebarButtonProps) => (
<Button
onClick={onClick}
variant={active ? "subtle" : "ghost"}
size="sm"
className="flex gap-2 w-full"
disabled={disabled}
>
{Icon && <Icon size={16} />}
{element && element}
<span className="flex flex-1 justify-start shrink-0">{label}</span>
</Button>
);

87
src/components/UI/Slider.tsx

@ -1,87 +0,0 @@
import { useId, useState } from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "@core/utils/cn.ts";
export interface SliderProps {
value: number[];
step?: number;
min?: number;
max: number;
onValueChange?: (value: number[]) => void;
onValueCommit?: (value: number[]) => void;
disabled?: boolean;
className?: string;
trackClassName?: string;
rangeClassName?: string;
thumbClassName?: string;
}
export function Slider({
value,
step = 1,
min = 0,
max,
onValueChange,
onValueCommit,
disabled = false,
className,
trackClassName,
rangeClassName,
thumbClassName,
...props
}: SliderProps) {
const [internalValue, setInternalValue] = useState<number[]>(value);
const isControlled = value !== undefined;
const currentValue = isControlled ? value! : internalValue;
const id = useId();
const handleValueChange = (newValue: number[]) => {
if (!isControlled) setInternalValue(newValue);
onValueChange?.(newValue);
};
const handleValueCommit = (newValue: number[]) => {
onValueCommit?.(newValue);
};
return (
<SliderPrimitive.Root
className={cn(
"relative flex items-center select-none touch-none",
className,
)}
value={currentValue}
step={step}
min={min}
max={max}
disabled={disabled}
onValueChange={handleValueChange}
onValueCommit={handleValueCommit}
{...props}
>
<SliderPrimitive.Track
className={cn(
"relative h-2 flex-1 rounded-full bg-slate-200",
trackClassName,
)}
>
<SliderPrimitive.Range
className={cn(
"absolute h-full rounded-full bg-blue-500",
rangeClassName,
)}
/>
</SliderPrimitive.Track>
{currentValue.map((_, i) => (
<SliderPrimitive.Thumb
key={`${id}-thumb-${i}`}
className={cn(
"block w-4 h-4 rounded-full bg-white border border-slate-400 shadow-md",
thumbClassName,
)}
aria-label={`Thumb ${i + 1}`}
/>
))}
</SliderPrimitive.Root>
);
}

2
src/components/UI/Tabs.tsx

@ -12,7 +12,7 @@ const TabsList = React.forwardRef<
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex flex-wrap items-center rounded-md p-1 mt-2 bg-slate-200 dark:bg-slate-100",
"inline-flex flex-wrap items-center rounded-md p-1 mt-2 bg-slate-200 dark:bg-slate-200",
className,
)}
{...props}

4
src/components/UI/Toast.tsx

@ -28,7 +28,7 @@ const toastVariants = cva(
variants: {
variant: {
default:
"border bg-white text-slate-900 dark:text-slate-100 dark:bg-slate-800",
"border bg-backgound-primary text-foreground dark:bg-white dark:border-slate-600 dark:text-slate-900",
destructive:
"group destructive bg-red-600 text-white dark:border-red-900 dark:bg-red-900 dark:text-red-50",
},
@ -61,7 +61,7 @@ const ToastAction = React.forwardRef<
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 hover:group-[.destructive]:border-destructive/30 hover:group-[.destructive]:bg-destructive hover:group-[.destructive]:text-destructive-foreground focus:group-[.destructive]:ring-destructive",
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 hover:group-[.destructive]:border-destructive/30 hover:group-[.destructive]:bg-destructive hover:group-[.destructive]:text-destructive-foreground focus:group-[.destructive]:ring-destructive",
className,
)}
{...props}

2
src/components/UI/Tooltip.tsx

@ -19,7 +19,7 @@ const TooltipContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border border-slate-100 bg-white px-3 py-1.5 text-sm text-slate-700 shadow-md animate-in fade-in-50 data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 dark:border-slate-800 dark:bg-slate-800 dark:text-slate-200",
"z-50 overflow-hidden rounded-md border border-slate-100 bg-white px-3 py-1.5 text-sm text-slate-700 shadow-md animate-in fade-in-50 data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 dark:border-slate-800 dark:bg-slate-800 dark:text-slate-400",
className,
)}
{...props}

2
src/components/UI/Typography/Heading.tsx

@ -4,7 +4,7 @@ const headingStyles = {
h1: "scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl",
h2:
"scroll-m-20 border-b border-b-slate-200 pb-2 text-3xl font-semibold tracking-tight transition-colors first:mt-0 dark:border-b-slate-700",
h3: "scroll-m-20 text-lg font-semibold tracking-tight",
h3: "scroll-m-20 text-2xl font-semibold tracking-tight",
h4: "scroll-m-20 text-xl font-semibold tracking-tight",
h5: "scroll-m-20 text-lg font-medium tracking-tight",
};

2
src/components/UI/Typography/Link.tsx

@ -12,7 +12,7 @@ export const Link = ({ href, children, className }: LinkProps) => (
target="_blank"
rel="noopener noreferrer"
className={cn(
"font-medium text-slate-900 underline underline-offset-4 dark:text-slate-200",
"font-medium text-slate-900 underline underline-offset-4 dark:text-slate-900",
className,
)}
>

10
src/components/generic/Mono.tsx

@ -1,17 +1,11 @@
import { cn } from "@core/utils/cn.ts";
interface MonoProps extends React.HTMLAttributes<HTMLSpanElement> {
children: React.ReactNode;
className?: string;
}
export const Mono = ({
children,
className,
...rest
}: MonoProps) => {
}: JSX.IntrinsicElements["span"]) => {
return (
<span
className={cn("font-mono text-sm text-text-secondary", className)}
className={`font-mono text-sm text-text-secondary ${className ?? ""}`}
{...rest}
>
{children}

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

@ -17,11 +17,11 @@ export interface Heading {
* @returns number of hopsAway or `0` if hopsAway is 'Direct'
*/
function numericHops(hopsAway: string): number {
if (hopsAway.match(/direct/i)) {
if(hopsAway.match(/direct/i)){
return 0;
}
if (hopsAway.match(/\d+\s+hop/gi)) {
return Number(hopsAway.match(/(\d+)\s+hop/i)?.[1]);
if ( hopsAway.match(/\d+\s+hop/gi) ) {
return Number( hopsAway.match(/(\d+)\s+hop/i)?.[1] );
}
return Number.MAX_SAFE_INTEGER;
}
@ -46,6 +46,7 @@ export const Table = ({ headings, rows }: TableProps) => {
const aValue = a[columnIndex].props.children;
const bValue = b[columnIndex].props.children;
// Custom comparison for 'Last Heard' column
if (sortColumn === "Last Heard") {
const aTimestamp = aValue.props.timestamp ?? 0;
const bTimestamp = bValue.props.timestamp ?? 0;
@ -59,10 +60,11 @@ export const Table = ({ headings, rows }: TableProps) => {
return 0;
}
// Custom comparison for 'Connection' column
if (sortColumn === "Connection") {
const aNumHops = numericHops(aValue instanceof Array ? aValue[0] : aValue);
const bNumHops = numericHops(bValue instanceof Array ? bValue[0] : bValue);
if (aNumHops < bNumHops) {
return sortOrder === "asc" ? -1 : 1;
}
@ -72,6 +74,7 @@ export const Table = ({ headings, rows }: TableProps) => {
return 0;
}
// Default comparison for other columns
if (aValue < bValue) {
return sortOrder === "asc" ? -1 : 1;
}
@ -83,16 +86,17 @@ export const Table = ({ headings, rows }: TableProps) => {
return (
<table className="min-w-full">
<thead className="text-xs font-semibold">
<thead className="bg-backgound-primary text-sm font-semibold text-text-primary">
<tr>
{headings.map((heading) => (
<th
key={heading.title}
scope="col"
className={`py-2 pr-3 text-left ${heading.sortable
? "cursor-pointer hover:brightness-hover active:brightness-press"
: ""
}`}
className={`py-2 pr-3 text-left ${
heading.sortable
? "cursor-pointer hover:brightness-hover active:brightness-press"
: ""
}`}
onClick={() => heading.sortable && headingSort(heading.title)}
onKeyUp={() => heading.sortable && headingSort(heading.title)}
>
@ -107,29 +111,28 @@ export const Table = ({ headings, rows }: TableProps) => {
))}
</tr>
</thead>
<tbody className="max-w-fit">
{sortedRows.map((row, index) => {
<tbody>
{sortedRows.map((row, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: TODO: Once this table is sortable, this should get fixed.
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`}>
{row.map((item, index) => {
return (index === 0 ?
<th
key={item.key ?? index}
className="whitespace-nowrap py-2 text-sm text-text-secondary first:pl-2"
scope="row"
>
{item}
</th> :
<td
key={item.key ?? index}
className="whitespace-nowrap py-2 text-sm text-text-secondary first:pl-2"
>
{item}
</td>)
})}
<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`}>
{row.map((item, index) => (
index === 0 ?
<th
key={item.key ?? index}
className="whitespace-nowrap py-2 text-sm text-text-secondary first:pl-2"
scope="row"
>
{item}
</th> :
<td
key={item.key ?? index}
className="whitespace-nowrap py-2 text-sm text-text-secondary first:pl-2"
>
{item}
</td>
))}
</tr>
);
})}
))}
</tbody>
</table>
);

40
src/core/dto/NodeNumToNodeInfoDTO.ts

@ -1,40 +0,0 @@
import { create } from "@bufbuild/protobuf";
import { Protobuf } from "@meshtastic/core";
class NodeInfoFactory {
private static createDefaultUser(num: number): Protobuf.Mesh.User {
const userIdHex = num.toString(16).toUpperCase().padStart(2, '0');
const userId = `!${userIdHex}`;
const last4 = userIdHex.slice(-4);
const longName = `Meshtastic ${last4}`;
const shortName = last4;
const hwModel = Protobuf.Mesh.HardwareModel.UNSET;
return create(Protobuf.Mesh.UserSchema, {
id: userId,
longName: longName,
shortName: shortName,
hwModel: hwModel,
isLicensed: false,
});
}
public static ensureDefaultUser(node: Protobuf.Mesh.NodeInfo): Protobuf.Mesh.NodeInfo {
if (!node) {
return node;
}
if (!node.user) {
if (node.num === undefined || node.num === null) {
console.error(`NodeInfoFactory.ensureDefaultUser: Cannot create default user for node because 'num' is missing.`, node);
return node;
}
node.user = NodeInfoFactory.createDefaultUser(node.num);
}
return node;
}
}
export default NodeInfoFactory;

51
src/core/dto/PacketToMessageDTO.ts

@ -1,51 +0,0 @@
import type { Types } from "@meshtastic/js";
import { Message, MessageType, MessageState } from "../stores/messageStore/index.ts";
class PacketToMessageDTO {
channel: Types.ChannelNumber;
to: number;
from: number;
date: number; // (timestamp ms)
messageId: number;
state: MessageState;
message: string;
type: MessageType;
constructor(data: Types.PacketMetadata<string>, nodeNum: number) {
this.channel = data.channel;
this.to = data.to;
this.from = data.from;
this.messageId = data.id;
this.state = data.from !== nodeNum ? MessageState.Ack : MessageState.Waiting;
this.message = data.data;
this.type = (data.type === 'direct') ? MessageType.Direct : MessageType.Broadcast;
let dateTimestamp = Date.now();
if (data.rxTime instanceof Date) {
const timeValue = data.rxTime.getTime();
if (!isNaN(timeValue)) {
dateTimestamp = timeValue;
}
}
else if (data.rxTime != null) {
console.warn(`Received rxTime in PacketToMessageDTO was not a Date object as expected (type: ${typeof data.rxTime}, value: ${data.rxTime}). Using current time as fallback.`);
}
this.date = dateTimestamp;
}
toMessage(): Message {
return {
channel: this.channel,
to: this.to,
from: this.from,
date: this.date,
messageId: this.messageId,
state: this.state,
message: this.message,
type: this.type,
};
}
}
export default PacketToMessageDTO;

51
src/core/hooks/useCopyToClipboard.ts

@ -1,51 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
interface UseCopyToClipboardProps {
timeout?: number;
}
export function useCopyToClipboard({ timeout = 2000 }: UseCopyToClipboardProps = {}) {
const [isCopied, setIsCopied] = useState<boolean>(false);
const timeoutRef = useRef<number | null>(null);
useEffect(() => {
return () => {
if (timeoutRef.current) {
globalThis.clearTimeout(timeoutRef.current);
}
};
}, []);
const copy = useCallback(
async (text: string) => {
if (!navigator?.clipboard) {
console.warn('Clipboard API not available');
setIsCopied(false);
return false;
}
if (timeoutRef.current) {
globalThis.clearTimeout(timeoutRef.current);
}
try {
await navigator.clipboard.writeText(text);
setIsCopied(true);
timeoutRef.current = globalThis.setTimeout(() => {
setIsCopied(false);
timeoutRef.current = null;
}, timeout);
return true;
} catch (error) {
console.error('Failed to copy text to clipboard:', error);
setIsCopied(false);
return false;
}
},
[timeout]
);
return { isCopied, copy };
}

294
src/core/hooks/useNodeFilters.ts

@ -1,294 +0,0 @@
import { useCallback, useMemo, useState } from "react";
import { Protobuf } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
interface BooleanFilter {
key: string;
label: string;
group: string;
type: "boolean";
predicate: (node: Protobuf.Mesh.NodeInfo, value: boolean) => boolean;
}
interface RangeFilter {
key: string;
label: string;
group: string;
type: "range";
bounds: [number, number];
predicate: (node: Protobuf.Mesh.NodeInfo, value: [number, number]) => boolean;
}
interface SearchFilter {
key: string;
label: string;
group: string;
type: "search";
predicate: (node: Protobuf.Mesh.NodeInfo, value: string) => boolean;
}
interface MultiFilter {
key: string;
label: string;
group: string;
type: "multi";
options: string[];
predicate: (node: Protobuf.Mesh.NodeInfo, value: string[]) => boolean;
}
export type FilterConfig =
| BooleanFilter
| RangeFilter
| SearchFilter
| MultiFilter;
export type FilterValueMap = {
[C in FilterConfig as C["key"]]: C extends BooleanFilter ? boolean
: C extends RangeFilter ? [number, number]
: C extends SearchFilter ? string
: C extends MultiFilter ? string[]
: never;
};
// Defines all node filters in this object
export const filterConfigs: FilterConfig[] = [
{
key: "searchText",
label: "Node name/number",
group: "General",
type: "search",
predicate: (node, text: string) => {
if (!text) return true;
const shortName = node.user?.shortName?.toString().toLowerCase() ?? "";
const longName = node.user?.longName?.toString().toLowerCase() ?? "";
const nodeNum = node.num?.toString() ?? "";
const nodeNumHex = numberToHexUnpadded(node.num) ?? "";
const search = text.toLowerCase();
return shortName.includes(search) || longName.includes(search) ||
nodeNum.includes(search) ||
nodeNumHex.includes(search.replace(/!/g, ""));
},
},
{
key: "hopRange",
label: "Number of hops",
group: "General",
type: "range",
bounds: [0, 7],
predicate: (node, [min, max]: [number, number]) => {
const hops = node.hopsAway ?? 7;
return hops >= min && hops <= max;
},
},
{
key: "lastHeard",
label: "Last heard",
group: "General",
type: "range",
bounds: [0, 864000], // 10 days
predicate: (node, [min, max]: [number, number]) => {
const secondsAgo = Date.now() / 1000 - node.lastHeard;
return (secondsAgo >= min && secondsAgo <= max) ||
(secondsAgo >= min && max == 864000);
},
},
{
key: "favOnly",
label: "Show favourites only",
group: "General",
type: "boolean",
predicate: (node, favOnly: boolean) => !favOnly || node.isFavorite,
},
{
key: "viaMqtt",
label: "Hide MQTT-connected nodes",
group: "General",
type: "boolean",
predicate: (node, hide: boolean) => !hide || !node.viaMqtt,
},
{
key: "snr",
label: "SNR (db)",
group: "Metrics",
type: "range",
bounds: [-20, 10],
predicate: (node, [min, max]: [number, number]) => {
const snr = node.snr ?? -20;
return snr >= min && snr <= max;
},
},
{
key: "channelUtilization",
label: "Channel Utilization (%)",
group: "Metrics",
type: "range",
bounds: [0, 100],
predicate: (node, [min, max]: [number, number]) => {
const channelUtilization = node.deviceMetrics?.channelUtilization ?? 0;
return channelUtilization >= min && channelUtilization <= max;
},
},
{
key: "airUtilTx",
label: "Airtime Utilization (%)",
group: "Metrics",
type: "range",
bounds: [0, 100],
predicate: (node, [min, max]: [number, number]) => {
const airUtilTx = node.deviceMetrics?.airUtilTx ?? 0;
return airUtilTx >= min && airUtilTx <= max;
},
},
{
key: "battery",
label: "Battery level (%)",
group: "Metrics",
type: "range",
bounds: [0, 101],
predicate: (node, [min, max]: [number, number]) => {
const batt = node.deviceMetrics?.batteryLevel ?? 101;
return batt >= min && batt <= max;
},
},
{
key: "voltage",
label: "Battery voltage (V)",
group: "Metrics",
type: "range",
bounds: [0.1, 5.0],
predicate: (node, [min, max]: [number, number]) => {
const batt = node.deviceMetrics?.voltage ?? 5;
return batt >= min && batt <= max;
},
},
{
key: "role",
label: "Role",
group: "Role",
type: "multi",
options: Object.keys(Protobuf.Config.Config_DeviceConfig_Role)
.filter((k) => isNaN(Number(k)))
.map((k) => {
const spaced = k.replace(/_/g, " ");
return spaced.charAt(0).toUpperCase() + spaced.slice(1).toLowerCase();
}),
predicate: (node, selected) => {
return selected.map((k) => {
const unSpaced = k.replace(/ /g, "_");
return unSpaced.toUpperCase();
}).includes(
Protobuf.Config.Config_DeviceConfig_Role[node.user?.role ?? 0],
);
},
},
{
key: "hwModel",
label: "Hardware model",
group: "Hardware",
type: "multi",
options: Object.keys(Protobuf.Mesh.HardwareModel)
.filter((k) => isNaN(Number(k)))
.map((k) => {
return k.replace(/_/g, " ");
}),
predicate: (node, selected) => {
return selected.map((k) => {
const unSpaced = k.replace(/ /g, "_");
return unSpaced.toUpperCase();
}).includes(Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]);
},
},
];
export function useNodeFilters(nodes: Protobuf.Mesh.NodeInfo[]) {
const defaultState = useMemo(() => {
return filterConfigs.reduce((acc, cfg) => {
switch (cfg.type) {
case "boolean":
acc[cfg.key] = false;
break;
case "range":
acc[cfg.key] = cfg.bounds;
break;
case "search":
acc[cfg.key] = "";
break;
case "multi":
acc[cfg.key] = cfg.options;
break;
}
return acc;
}, {} as FilterValueMap);
}, []);
const [filters, setFilters] = useState<FilterValueMap>(
defaultState,
);
const groupedFilterConfigs = useMemo(() => {
return filterConfigs.reduce<Record<string, FilterConfig[]>>((acc, cfg) => {
const g = "group" in cfg ? cfg.group : "General";
if (!acc[g]) acc[g] = [];
acc[g].push(cfg);
return acc;
}, {});
}, [filterConfigs]);
const resetFilters = useCallback(() => {
setFilters(defaultState);
}, [defaultState]);
const onFilterChange = useCallback(
<K extends keyof FilterValueMap>(key: K, value: FilterValueMap[K]) => {
setFilters((f) => ({ ...f, [key]: value }));
},
[],
);
const filteredNodes = useMemo(
() =>
nodes.filter((node) =>
filterConfigs.every((cfg) => {
const val = filters[cfg.key];
switch (cfg.type) {
case "boolean":
if (typeof val !== "boolean") return true;
return cfg.predicate(node, val);
case "range": {
if (
!Array.isArray(val) ||
val.length !== 2 ||
typeof val[0] !== "number" ||
typeof val[1] !== "number"
) {
return true;
}
const tuple: [number, number] = [val[0], val[1]];
return cfg.predicate(node, tuple);
}
case "multi": {
const safeArray = (() => {
if (!Array.isArray(val)) return [];
return val.filter((x): x is string => typeof x === "string");
})();
return cfg.predicate(node, safeArray);
}
case "search":
if (typeof val !== "string") return true;
return cfg.predicate(node, val);
}
})
),
[nodes, filters],
);
return {
filters,
defaultState,
onFilterChange,
resetFilters,
filteredNodes,
groupedFilterConfigs,
};
}

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

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

20
src/core/hooks/usePasswordVisibilityToggle.ts

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

16
src/core/stores/appStore.ts

@ -1,3 +1,4 @@
import { Types } from "@meshtastic/core";
import { produce } from "immer";
import { create } from "zustand";
@ -24,11 +25,14 @@ interface AppState {
id: number;
num: number;
}[];
rasterSources: RasterSource[];
commandPaletteOpen: boolean;
nodeNumToBeRemoved: number;
connectDialogOpen: boolean;
nodeNumDetails: number;
activeChat: number;
chatType: "broadcast" | "direct";
errors: ErrorState[];
setRasterSources: (sources: RasterSource[]) => void;
@ -41,6 +45,8 @@ interface AppState {
setNodeNumToBeRemoved: (nodeNum: number) => void;
setConnectDialogOpen: (open: boolean) => void;
setNodeNumDetails: (nodeNum: number) => void;
setActiveChat: (chat: number) => void;
setChatType: (type: "broadcast" | "direct") => void;
// Error management
hasErrors: () => boolean;
@ -61,6 +67,8 @@ export const useAppStore = create<AppState>()((set, get) => ({
connectDialogOpen: false,
nodeNumToBeRemoved: 0,
nodeNumDetails: 0,
activeChat: Types.ChannelNumber.Primary,
chatType: "broadcast",
errors: [],
setRasterSources: (sources: RasterSource[]) => {
@ -119,6 +127,14 @@ export const useAppStore = create<AppState>()((set, get) => ({
set(() => ({
nodeNumDetails: nodeNum,
})),
setActiveChat: (chat) =>
set(() => ({
activeChat: chat,
})),
setChatType: (type) =>
set(() => ({
chatType: type,
})),
hasErrors: () => {
const state = get();
return state.errors.length > 0;

484
src/core/stores/deviceStore.ts

@ -6,13 +6,34 @@ import { create as createStore } from "zustand";
export type Page = "messages" | "map" | "config" | "channels" | "nodes";
export interface MessageWithState extends Types.PacketMetadata<string> {
state: MessageState;
}
export type MessageState = "ack" | "waiting" | Protobuf.Mesh.Routing_Error;
export interface ProcessPacketParams {
from: number;
snr: number;
time: number;
}
export type DialogVariant = keyof Device["dialog"];
export type DialogVariant =
| "import"
| "QR"
| "shutdown"
| "reboot"
| "rebootOTA"
| "deviceName"
| "nodeRemoval"
| "pkiBackup"
| "nodeDetails"
| "unsafeRoles"
| "refreshKeys";
type QueueStatus = {
res: number, free: number, maxlen: number
}
type NodeError = {
node: number;
@ -28,7 +49,12 @@ export interface Device {
workingConfig: Protobuf.Config.Config[];
workingModuleConfig: Protobuf.ModuleConfig.ModuleConfig[];
hardware: Protobuf.Mesh.MyNodeInfo;
nodes: Map<number, Protobuf.Mesh.NodeInfo>;
metadata: Map<number, Protobuf.Mesh.DeviceMetadata>;
messages: {
direct: Map<number, MessageWithState[]>;
broadcast: Map<Types.ChannelNumber, MessageWithState[]>;
};
traceroutes: Map<
number,
Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>[]
@ -38,10 +64,11 @@ export interface Device {
activePage: Page;
activeNode: number;
waypoints: Protobuf.Mesh.Waypoint[];
// currentMetrics: Protobuf.DeviceMetrics;
pendingSettingsChanges: boolean;
messageDraft: string;
unreadCounts: Map<number, number>;
nodesMap: Map<number, Protobuf.Mesh.NodeInfo>; // dont access directly, use getNodes, or getNode
queueStatus: QueueStatus,
isQueueingMessages: boolean,
dialog: {
import: boolean;
QR: boolean;
@ -54,15 +81,16 @@ export interface Device {
nodeDetails: boolean;
unsafeRoles: boolean;
refreshKeys: boolean;
deleteMessages: boolean;
};
setStatus: (status: Types.DeviceStatusEnum) => void;
setConfig: (config: Protobuf.Config.Config) => void;
setModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => void;
setWorkingConfig: (config: Protobuf.Config.Config) => void;
setWorkingModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => void;
setHardware: (hardware: Protobuf.Mesh.MyNodeInfo) => void;
// setMetrics: (metrics: Types.PacketMetadata<Protobuf.Telemetry>) => void;
setActivePage: (page: Page) => void;
setActiveNode: (node: number) => void;
setPendingSettingsChanges: (state: boolean) => void;
@ -72,27 +100,29 @@ export interface Device {
addUser: (user: Types.PacketMetadata<Protobuf.Mesh.User>) => void;
addPosition: (position: Types.PacketMetadata<Protobuf.Mesh.Position>) => void;
addConnection: (connection: MeshDevice) => void;
addMessage: (message: MessageWithState) => void;
addTraceRoute: (
traceroute: Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>,
) => void;
addMetadata: (from: number, metadata: Protobuf.Mesh.DeviceMetadata) => void;
removeNode: (nodeNum: number) => void;
setMessageState: (
type: "direct" | "broadcast",
channelIndex: Types.ChannelNumber,
to: number,
from: number,
messageId: number,
state: MessageState,
) => void;
setDialogOpen: (dialog: DialogVariant, open: boolean) => void;
getDialogOpen: (dialog: DialogVariant) => boolean;
processPacket: (data: ProcessPacketParams) => void;
setMessageDraft: (message: string) => void;
setQueueStatus: (status: QueueStatus) => void;
setNodeError: (nodeNum: number, error: string) => void;
clearNodeError: (nodeNum: number) => void;
getNodeError: (nodeNum: number) => NodeError | undefined;
hasNodeError: (nodeNum: number) => boolean;
incrementUnread: (nodeNum: number) => void;
resetUnread: (nodeNum: number) => void;
getNodes: (
filter?: (node: Protobuf.Mesh.NodeInfo) => boolean,
) => Protobuf.Mesh.NodeInfo[];
getNodesLength: () => number;
getNode: (nodeNum: number) => Protobuf.Mesh.NodeInfo | undefined;
hasNodeError: (nodeNum: number) => boolean
}
export interface DeviceState {
@ -110,7 +140,6 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
remoteDevices: new Map(),
addDevice: (id: number) => {
set(
produce<DeviceState>((draft) => {
draft.devices.set(id, {
@ -122,12 +151,21 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
workingConfig: [],
workingModuleConfig: [],
hardware: create(Protobuf.Mesh.MyNodeInfoSchema),
nodes: new Map(),
metadata: new Map(),
messages: {
direct: new Map(),
broadcast: new Map(),
},
traceroutes: new Map(),
connection: undefined,
activePage: "messages",
activeNode: 0,
waypoints: [],
queueStatus: {
res: 0, free: 0, maxlen: 0
},
isQueueingMessages: false,
dialog: {
import: false,
QR: false,
@ -140,13 +178,11 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
unsafeRoles: false,
refreshKeys: false,
rebootOTA: false,
deleteMessages: false,
},
pendingSettingsChanges: false,
messageDraft: "",
nodeErrors: new Map(),
unreadCounts: new Map(),
nodesMap: new Map(),
setStatus: (status: Types.DeviceStatusEnum) => {
set(
@ -162,16 +198,40 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
set(
produce<DeviceState>((draft) => {
const device = draft.devices.get(id);
if (device) {
switch (config.payloadVariant.case) {
case "device": { device.config.device = config.payloadVariant.value; break; }
case "position": { device.config.position = config.payloadVariant.value; break; }
case "power": { device.config.power = config.payloadVariant.value; break; }
case "network": { device.config.network = config.payloadVariant.value; break; }
case "display": { device.config.display = config.payloadVariant.value; break; }
case "lora": { device.config.lora = config.payloadVariant.value; break; }
case "bluetooth": { device.config.bluetooth = config.payloadVariant.value; break; }
case "security": { device.config.security = config.payloadVariant.value; }
case "device": {
device.config.device = config.payloadVariant.value;
break;
}
case "position": {
device.config.position = config.payloadVariant.value;
break;
}
case "power": {
device.config.power = config.payloadVariant.value;
break;
}
case "network": {
device.config.network = config.payloadVariant.value;
break;
}
case "display": {
device.config.display = config.payloadVariant.value;
break;
}
case "lora": {
device.config.lora = config.payloadVariant.value;
break;
}
case "bluetooth": {
device.config.bluetooth = config.payloadVariant.value;
break;
}
case "security": {
device.config.security = config.payloadVariant.value;
}
}
}
}),
@ -181,20 +241,66 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
set(
produce<DeviceState>((draft) => {
const device = draft.devices.get(id);
if (device) {
switch (config.payloadVariant.case) {
case "mqtt": { device.moduleConfig.mqtt = config.payloadVariant.value; break; }
case "serial": { device.moduleConfig.serial = config.payloadVariant.value; break; }
case "externalNotification": { device.moduleConfig.externalNotification = config.payloadVariant.value; break; }
case "storeForward": { device.moduleConfig.storeForward = config.payloadVariant.value; break; }
case "rangeTest": { device.moduleConfig.rangeTest = config.payloadVariant.value; break; }
case "telemetry": { device.moduleConfig.telemetry = config.payloadVariant.value; break; }
case "cannedMessage": { device.moduleConfig.cannedMessage = config.payloadVariant.value; break; }
case "audio": { device.moduleConfig.audio = config.payloadVariant.value; break; }
case "neighborInfo": { device.moduleConfig.neighborInfo = config.payloadVariant.value; break; }
case "ambientLighting": { device.moduleConfig.ambientLighting = config.payloadVariant.value; break; }
case "detectionSensor": { device.moduleConfig.detectionSensor = config.payloadVariant.value; break; }
case "paxcounter": { device.moduleConfig.paxcounter = config.payloadVariant.value; break; }
case "mqtt": {
device.moduleConfig.mqtt = config.payloadVariant.value;
break;
}
case "serial": {
device.moduleConfig.serial = config.payloadVariant.value;
break;
}
case "externalNotification": {
device.moduleConfig.externalNotification =
config.payloadVariant.value;
break;
}
case "storeForward": {
device.moduleConfig.storeForward =
config.payloadVariant.value;
break;
}
case "rangeTest": {
device.moduleConfig.rangeTest =
config.payloadVariant.value;
break;
}
case "telemetry": {
device.moduleConfig.telemetry =
config.payloadVariant.value;
break;
}
case "cannedMessage": {
device.moduleConfig.cannedMessage =
config.payloadVariant.value;
break;
}
case "audio": {
device.moduleConfig.audio = config.payloadVariant.value;
break;
}
case "neighborInfo": {
device.moduleConfig.neighborInfo =
config.payloadVariant.value;
break;
}
case "ambientLighting": {
device.moduleConfig.ambientLighting =
config.payloadVariant.value;
break;
}
case "detectionSensor": {
device.moduleConfig.detectionSensor =
config.payloadVariant.value;
break;
}
case "paxcounter": {
device.moduleConfig.paxcounter =
config.payloadVariant.value;
break;
}
}
}
}),
@ -204,30 +310,40 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
set(
produce<DeviceState>((draft) => {
const device = draft.devices.get(id);
if (!device) return;
const index = device.workingConfig.findIndex(
if (!device) {
return;
}
const workingConfigIndex = device?.workingConfig.findIndex(
(wc) => wc.payloadVariant.case === config.payloadVariant.case,
);
if (index !== -1) {
device.workingConfig[index] = config;
if (workingConfigIndex !== -1) {
device.workingConfig[workingConfigIndex] = config;
} else {
device.workingConfig.push(config);
device?.workingConfig.push(config);
}
}),
);
},
setWorkingModuleConfig: (moduleConfig: Protobuf.ModuleConfig.ModuleConfig) => {
setWorkingModuleConfig: (
moduleConfig: Protobuf.ModuleConfig.ModuleConfig,
) => {
set(
produce<DeviceState>((draft) => {
const device = draft.devices.get(id);
if (!device) return;
const index = device.workingModuleConfig.findIndex(
(wmc) => wmc.payloadVariant.case === moduleConfig.payloadVariant.case,
);
if (index !== -1) {
device.workingModuleConfig[index] = moduleConfig;
if (!device) {
return;
}
const workingModuleConfigIndex = device?.workingModuleConfig
.findIndex(
(wmc) =>
wmc.payloadVariant.case ===
moduleConfig.payloadVariant.case,
);
if (workingModuleConfigIndex !== -1) {
device.workingModuleConfig[workingModuleConfigIndex] =
moduleConfig;
} else {
device.workingModuleConfig.push(moduleConfig);
device?.workingModuleConfig.push(moduleConfig);
}
}),
);
@ -242,6 +358,50 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
}),
);
},
// setMetrics: (metrics: Types.PacketMetadata<Protobuf.Telemetry>) => {
// set(
// produce<DeviceState>((draft) => {
// const device = draft.devices.get(id);
// let node = device?.nodes.find(
// (n) => n.data.num === metrics.from
// );
// if (node) {
// switch (metrics.data.variant.case) {
// case "deviceMetrics":
// if (device) {
// if (metrics.data.variant.value.batteryLevel) {
// device.currentMetrics.batteryLevel =
// metrics.data.variant.value.batteryLevel;
// }
// if (metrics.data.variant.value.voltage) {
// device.currentMetrics.voltage =
// metrics.data.variant.value.voltage;
// }
// if (metrics.data.variant.value.airUtilTx) {
// device.currentMetrics.airUtilTx =
// metrics.data.variant.value.airUtilTx;
// }
// if (metrics.data.variant.value.channelUtilization) {
// device.currentMetrics.channelUtilization =
// metrics.data.variant.value.channelUtilization;
// }
// }
// node.deviceMetrics.push({
// metric: metrics.data.variant.value,
// timestamp: metrics.rxTime
// });
// break;
// case "environmentMetrics":
// node.environmentMetrics.push({
// metric: metrics.data.variant.value,
// timestamp: metrics.rxTime
// });
// break;
// }
// }
// })
// );
// },
setActivePage: (page) => {
set(
produce<DeviceState>((draft) => {
@ -266,9 +426,10 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
set(
produce<DeviceState>((draft) => {
const device = draft.devices.get(id);
if (device) {
device.channels.set(channel.index, channel);
if (!device) {
return;
}
device.channels.set(channel.index, channel);
}),
);
},
@ -277,9 +438,12 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
produce<DeviceState>((draft) => {
const device = draft.devices.get(id);
if (device) {
const index = device.waypoints.findIndex((wp) => wp.id === waypoint.id);
if (index !== -1) {
device.waypoints[index] = waypoint;
const waypointIndex = device.waypoints.findIndex(
(wp) => wp.id === waypoint.id,
);
if (waypointIndex !== -1) {
device.waypoints[waypointIndex] = waypoint;
} else {
device.waypoints.push(waypoint);
}
@ -288,14 +452,13 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
);
},
addNodeInfo: (nodeInfo) => {
console.log("Node Info", nodeInfo);
set(
produce<DeviceState>((draft) => {
const device = draft.devices.get(id);
if (!device) return;
device.nodesMap.set(nodeInfo.num, nodeInfo);
if (!device) {
return;
}
device.nodes.set(nodeInfo.num, nodeInfo);
}),
);
},
@ -316,11 +479,11 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
if (!device) {
return;
}
const currentNode = device.nodesMap.get(user.from) ?? create(Protobuf.Mesh.NodeInfoSchema);
const currentNode = device.nodes.get(user.from) ??
create(Protobuf.Mesh.NodeInfoSchema);
currentNode.user = user.data;
currentNode.num = user.from;
device.nodesMap.set(user.from, currentNode);
})
device.nodes.set(user.from, currentNode);
}),
);
},
addPosition: (position) => {
@ -330,11 +493,11 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
if (!device) {
return;
}
const currentNode = device.nodesMap.get(position.from) ?? create(Protobuf.Mesh.NodeInfoSchema);
const currentNode = device.nodes.get(position.from) ??
create(Protobuf.Mesh.NodeInfoSchema);
currentNode.position = position.data;
currentNode.num = position.from;
device.nodesMap.set(position.from, currentNode);
})
device.nodes.set(position.from, currentNode);
}),
);
},
addConnection: (connection) => {
@ -347,13 +510,39 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
}),
);
},
addMessage: (message) => {
set(
produce<DeviceState>((draft) => {
const device = draft.devices.get(id);
if (!device) {
return;
}
const messageGroup = device.messages[message.type];
const messageIndex = message.type === "direct"
? message.from === device.hardware.myNodeNum
? message.to
: message.from
: message.channel;
const messages = messageGroup.get(messageIndex);
if (messages) {
messages.push(message);
messageGroup.set(messageIndex, messages);
} else {
messageGroup.set(messageIndex, [message]);
}
}),
);
},
addMetadata: (from, metadata) => {
set(
produce<DeviceState>((draft) => {
const device = draft.devices.get(id);
if (device) {
device.metadata.set(from, metadata);
if (!device) {
return;
}
device.metadata.set(from, metadata);
}),
);
},
@ -361,10 +550,17 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
set(
produce<DeviceState>((draft) => {
const device = draft.devices.get(id);
if (!device) return;
const routes = device.traceroutes.get(traceroute.from) ?? [];
routes.push(traceroute);
device.traceroutes.set(traceroute.from, routes);
if (!device) {
return;
}
const nodetraceroutes = device.traceroutes.get(traceroute.from);
if (nodetraceroutes) {
nodetraceroutes.push(traceroute);
device.traceroutes.set(traceroute.from, nodetraceroutes);
} else {
device.traceroutes.set(traceroute.from, [traceroute]);
}
}),
);
},
@ -373,38 +569,83 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
produce<DeviceState>((draft) => {
const device = draft.devices.get(id);
if (!device) {
return
return;
}
device.nodesMap.delete(nodeNum);
}))
device.nodes.delete(nodeNum);
}),
);
},
setMessageState: (
type: "direct" | "broadcast",
channelIndex: Types.ChannelNumber,
to: number,
from: number,
messageId: number,
state: MessageState,
) => {
set(
produce<DeviceState>((draft) => {
const device = draft.devices.get(id);
if (!device) {
return;
}
const messageGroup = device.messages[type];
const messageIndex = type === "direct"
? from === device.hardware.myNodeNum ? to : from
: channelIndex;
const messages = messageGroup.get(messageIndex);
if (!messages) {
return;
}
messageGroup.set(
messageIndex,
messages.map((msg) => {
if (msg.id === messageId) {
msg.state = state;
}
return msg;
}),
);
}),
);
},
setDialogOpen: (dialog: DialogVariant, open: boolean) => {
set(
produce<DeviceState>((draft) => {
const device = draft.devices.get(id);
if (device) {
device.dialog[dialog] = open;
if (!device) {
return;
}
device.dialog[dialog] = open;
}),
);
},
getDialogOpen: (dialog: DialogVariant) => {
const device = get().devices.get(id);
if (!device) throw new Error(`Device ${id} not found`);
if (!device) {
throw new Error("Device not found");
}
return device.dialog[dialog];
},
processPacket(data: ProcessPacketParams) {
set(
produce<DeviceState>((draft) => {
const device = draft.devices.get(id);
if (!device) return;
const node = device.nodesMap.get(data.from);
if (!device) {
return;
}
const node = device.nodes.get(data.from);
if (node) {
node.lastHeard = data.time;
node.snr = data.snr;
device.nodesMap.set(data.from, node);
device.nodes.set(data.from, {
...node,
lastHeard: data.time,
snr: data.snr,
});
} else {
device.nodesMap.set(
device.nodes.set(
data.from,
create(Protobuf.Mesh.NodeInfoSchema, {
num: data.from,
@ -426,98 +667,63 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
}),
);
},
setNodeError: (nodeNum, error) => {
setQueueStatus: (status: QueueStatus) => {
set(
produce<DeviceState>((draft) => {
const device = draft.devices.get(id);
if (device) {
device.nodeErrors.set(nodeNum, { node: nodeNum, error });
device.queueStatus = status;
device.queueStatus.free >= 10 ? true : false
}
}),
);
},
clearNodeError: (nodeNum: number) => {
setNodeError: (nodeNum, error) => {
set(
produce<DeviceState>((draft) => {
const device = draft.devices.get(id);
if (device) {
device.nodeErrors.delete(nodeNum);
device.nodeErrors.set(nodeNum, { node: nodeNum, error });
}
}),
);
},
getNodeError: (nodeNum: number) => {
const device = get().devices.get(id);
if (!device) throw new Error(`Device ${id} not found`);
return device.nodeErrors.get(nodeNum);
},
hasNodeError: (nodeNum: number) => {
const device = get().devices.get(id);
if (!device) throw new Error(`Device ${id} not found`);
return device.nodeErrors.has(nodeNum);
},
incrementUnread: (nodeNum: number) => {
set(
produce<DeviceState>((draft) => {
const device = draft.devices.get(id);
if (!device) return;
const currentCount = device.unreadCounts.get(nodeNum) ?? 0;
device.unreadCounts.set(nodeNum, currentCount + 1);
})
);
},
resetUnread: (nodeNum: number) => {
clearNodeError: (nodeNum: number) => {
set(
produce<DeviceState>((draft) => {
const device = draft.devices.get(id);
if (!device) return;
device.unreadCounts.set(nodeNum, 0);
if (device.unreadCounts.get(nodeNum) === 0) {
device.unreadCounts.delete(nodeNum);
if (device) {
device.nodeErrors.delete(nodeNum);
}
})
}),
);
},
getNodes: (filter?: (node: Protobuf.Mesh.NodeInfo) => boolean): Protobuf.Mesh.NodeInfo[] => {
const device = get().devices.get(id);
if (!device) {
return [];
}
const allNodes = Array.from(device.nodesMap.values()).filter(
(node) => node.num !== get().devices.get(id)?.hardware.myNodeNum);
if (filter) {
return allNodes.filter(filter);
}
return allNodes;
},
getNode: (nodeNum: number): Protobuf.Mesh.NodeInfo | undefined => {
getNodeError: (nodeNum: number) => {
const device = get().devices.get(id);
if (!device) {
return;
throw new Error("Device not found");
}
if (!device.nodesMap.has(nodeNum)) {
return undefined;
}
return device.nodesMap.get(nodeNum);
return device.nodeErrors.get(nodeNum);
},
getNodesLength: () => {
hasNodeError: (nodeNum: number) => {
const device = get().devices.get(id);
if (!device) {
return 0;
throw new Error("Device not found");
}
return device.nodesMap.size
return device.nodeErrors.has(nodeNum);
},
});
}),
);
const device = get().devices.get(id);
if (!device) {
throw new Error(`Failed to create or retrieve device with ID ${id}`);
throw new Error("Device not found");
}
return device;
},
removeDevice: (id) => {
set(
produce<DeviceState>((draft) => {

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

@ -1,212 +0,0 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { produce } from 'immer';
import { Types } from '@meshtastic/core';
import { storageWithMapSupport } from "../storage/indexDB.ts";
import { ChannelId, ClearMessageParams, ConversationId, GetMessagesParams, Message, MessageId, MessageLogMap, NodeNum, SetMessageStateParams } from "@core/stores/messageStore/types.ts";
export enum MessageState {
Ack = "ack",
Waiting = "waiting",
Failed = "failed",
}
export enum MessageType {
Direct = "direct",
Broadcast = "broadcast",
}
export function getConversationId(node1: NodeNum, node2: NodeNum): ConversationId {
return [node1, node2].sort((a, b) => a - b).join(':');
}
export interface MessageStore {
messages: {
direct: Map<ConversationId, MessageLogMap>;
broadcast: Map<ChannelId, MessageLogMap>;
};
};
export interface MessageStore {
messages: MessageStore['messages'];
draft: Map<Types.Destination, string>;
nodeNum: number; // This device's node number
activeChat: number; // Represents otherNodeNum for Direct, or channel for Broadcast
chatType: MessageType;
setNodeNum: (nodeNum: number) => void;
getMyNodeNum: () => number;
setActiveChat: (chat: number) => void;
setChatType: (type: MessageType) => void;
saveMessage: (message: Message) => void;
setMessageState: (params: SetMessageStateParams) => void;
getMessages: (params: GetMessagesParams) => Message[];
getDraft: (key: Types.Destination) => string;
setDraft: (key: Types.Destination, message: string) => void;
deleteAllMessages: () => void;
clearMessageByMessageId: (params: ClearMessageParams) => void;
clearDraft: (key: Types.Destination) => void;
}
const CURRENT_STORE_VERSION = 0;
export const useMessageStore = create<MessageStore>()(
// persist(
(set, get) => ({
messages: {
direct: new Map<ConversationId, MessageLogMap>(),
broadcast: new Map<ChannelId, MessageLogMap>(),
},
draft: new Map<number, string>(),
activeChat: 0,
chatType: MessageType.Broadcast,
nodeNum: 0,
setNodeNum: (nodeNum) => {
set(produce((state: MessageStore) => {
state.nodeNum = nodeNum;
}));
},
getMyNodeNum: () => get().nodeNum,
setActiveChat: (chat) => {
set(produce((state: MessageStore) => {
state.activeChat = chat;
}));
},
setChatType: (type) => {
set(produce((state: MessageStore) => {
state.chatType = type;
}));
},
saveMessage: (message: Message) => {
set(
produce((state: MessageStore) => {
if (message.type === MessageType.Direct) {
const conversationId = getConversationId(message.from, message.to);
if (!state.messages.direct.has(conversationId)) {
state.messages.direct.set(conversationId, new Map<MessageId, Message>());
}
state.messages.direct.get(conversationId)!.set(message.messageId, message);
} else if (message.type === MessageType.Broadcast) {
const channelId = message.channel as ChannelId;
if (!state.messages.broadcast.has(channelId)) {
state.messages.broadcast.set(channelId, new Map<MessageId, Message>());
}
state.messages.broadcast.get(channelId)!.set(message.messageId, message);
}
})
);
},
setMessageState: (params: SetMessageStateParams) => {
set(
produce((state: MessageStore) => {
let messageLog: MessageLogMap | undefined;
let targetMessage: Message | undefined;
if (params.type === MessageType.Direct) {
const conversationId = getConversationId(params.nodeA, params.nodeB);
messageLog = state.messages.direct.get(conversationId);
if (messageLog) {
targetMessage = messageLog.get(params.messageId);
}
} else { // Broadcast
messageLog = state.messages.broadcast.get(params.channelId);
if (messageLog) {
targetMessage = messageLog.get(params.messageId);
}
}
if (targetMessage) {
targetMessage.state = params.newState ?? MessageState.Ack;
} else {
console.warn(`Message or conversation/channel not found for state update. Params: ${JSON.stringify(params)}`);
}
})
);
},
getMessages: (params: GetMessagesParams): Message[] => {
const state = get();
let messageMap: MessageLogMap | undefined;
if (params.type === MessageType.Direct) {
const conversationId = getConversationId(params.nodeA, params.nodeB);
messageMap = state.messages.direct.get(conversationId);
} else {
messageMap = state.messages.broadcast.get(params.channelId);
}
if (messageMap === undefined) {
return [];
}
const messagesArray = Array.from(messageMap.values());
messagesArray.sort((a, b) => a.date - b.date);
return messagesArray;
},
clearMessageByMessageId: (params: ClearMessageParams) => {
set(
produce((state: MessageStore) => {
let messageLog: MessageLogMap | undefined;
let parentMap: Map<ConversationId | ChannelId, MessageLogMap>;
let parentKey: ConversationId | ChannelId;
if (params.type === MessageType.Direct) {
parentKey = getConversationId(params.nodeA, params.nodeB);
parentMap = state.messages.direct;
messageLog = parentMap.get(parentKey);
} else {
parentKey = params.channelId;
parentMap = state.messages.broadcast;
messageLog = parentMap.get(parentKey);
}
if (messageLog) {
const deleted = messageLog.delete(params.messageId);
if (deleted) {
console.log(`Deleted message ${params.messageId} from ${params.type} message ${parentKey}`);
// Clean up empty MessageLogMap and its entry in the parent map
if (messageLog.size === 0) {
parentMap.delete(parentKey);
console.log(`Cleaned up empty message entry for ${parentKey}`);
}
} else {
console.warn(`Message ${params.messageId} not found in ${params.type} chat ${parentKey} for deletion.`);
}
} else {
console.warn(`Message entry ${parentKey} not found for message deletion.`);
}
})
);
},
getDraft: (key) => {
return get().draft.get(key) ?? '';
},
setDraft: (key, message) => {
set(produce((state: MessageStore) => {
state.draft.set(key, message);
}));
},
clearDraft: (key) => {
set(produce((state: MessageStore) => {
state.draft.delete(key);
}));
},
deleteAllMessages: () => {
set(produce((state: MessageStore) => {
state.messages.direct = new Map<ConversationId, MessageLogMap>();
state.messages.broadcast = new Map<ChannelId, MessageLogMap>();
}));
}
}),
// {
// name: 'meshtastic-message-store',
// storage: storageWithMapSupport,
// version: CURRENT_STORE_VERSION,
// partialize: (state) => ({
// messages: state.messages,
// nodeNum: state.nodeNum,
// }),
// })
)

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

@ -1,486 +0,0 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import {
useMessageStore,
MessageType,
MessageState,
getConversationId,
} from './index.ts';
import type { ConversationId, ChannelId, MessageLogMap, Message } from './types.ts';
import { Types } from '@meshtastic/core';
vi.mock('../storage/indexDB.ts', () => {
let memoryStorage: Record<string, string> = {};
return {
storageWithMapSupport: {
getItem: vi.fn(async (name: string): Promise<string | null> => {
return memoryStorage[name] ?? null;
}),
setItem: vi.fn(async (name: string, value: string): Promise<void> => {
memoryStorage[name] = value;
}),
removeItem: vi.fn(async (name: string): Promise<void> => {
delete memoryStorage[name];
}),
},
};
});
const myNodeNum = 111;
const otherNodeNum1 = 222;
const otherNodeNum2 = 333;
const broadcastChannel: ChannelId = 0;
const directMessageToOther1: Message = {
type: MessageType.Direct,
from: myNodeNum,
to: otherNodeNum1,
channel: 0,
date: Date.now(),
messageId: 101,
state: MessageState.Waiting,
message: 'Hello other 1 from me',
};
const directMessageFromOther1: Message = {
type: MessageType.Direct,
from: otherNodeNum1,
to: myNodeNum,
channel: 0,
date: Date.now() + 1000,
messageId: 102,
state: MessageState.Waiting,
message: 'Hello me from other 1',
};
const directMessageToOther2: Message = {
type: MessageType.Direct,
from: myNodeNum,
to: otherNodeNum2,
channel: 0,
date: Date.now() + 2000,
messageId: 103,
state: MessageState.Waiting,
message: 'Hello other 2 from me',
};
const broadcastMessage1: Message = {
type: MessageType.Broadcast,
from: otherNodeNum1,
to: 0xffffffff,
channel: broadcastChannel,
date: Date.now() + 3000,
messageId: 201,
state: MessageState.Waiting,
message: 'Broadcast message 1',
};
const broadcastMessage2: Message = {
type: MessageType.Broadcast,
from: myNodeNum,
to: 0xffffffff,
channel: broadcastChannel,
date: Date.now() + 4000,
messageId: 202,
state: MessageState.Waiting,
message: 'Broadcast message 2',
};
describe('useMessageStore', () => {
const initialState = useMessageStore.getState();
beforeEach(() => {
useMessageStore.setState({
...initialState,
messages: {
direct: new Map<ConversationId, MessageLogMap>(),
broadcast: new Map<ChannelId, MessageLogMap>(),
},
draft: new Map<Types.Destination, string>(),
}, true);
});
it('should have correct initial state', () => {
const state = useMessageStore.getState();
expect(state.messages.direct).toBeInstanceOf(Map);
expect(state.messages.direct.size).toBe(0);
expect(state.messages.broadcast).toBeInstanceOf(Map);
expect(state.messages.broadcast.size).toBe(0);
expect(state.draft).toBeInstanceOf(Map);
expect(state.draft.size).toBe(0);
expect(state.nodeNum).toBe(0);
expect(state.activeChat).toBe(0);
expect(state.chatType).toBe(MessageType.Broadcast);
});
it('should set nodeNum', () => {
useMessageStore.getState().setNodeNum(myNodeNum);
expect(useMessageStore.getState().nodeNum).toBe(myNodeNum);
});
it('should set activeChat and chatType', () => {
useMessageStore.getState().setActiveChat(otherNodeNum1);
useMessageStore.getState().setChatType(MessageType.Direct);
expect(useMessageStore.getState().activeChat).toBe(otherNodeNum1);
expect(useMessageStore.getState().chatType).toBe(MessageType.Direct);
});
describe('saveMessage', () => {
it('should save a direct message with correct Map structure', () => {
useMessageStore.getState().saveMessage(directMessageToOther1);
const state = useMessageStore.getState();
const conversationId = getConversationId(directMessageToOther1.from, directMessageToOther1.to);
// Check if the conversation Map exists
expect(state.messages.direct.has(conversationId)).toBe(true);
const conversationLog = state.messages.direct.get(conversationId);
// Check if the inner Map (MessageLogMap) exists and is a Map
expect(conversationLog).toBeInstanceOf(Map);
// Check if the message exists within the inner Map
expect(conversationLog?.has(directMessageToOther1.messageId)).toBe(true);
// Check the message content
expect(conversationLog?.get(directMessageToOther1.messageId)).toEqual(directMessageToOther1);
});
it('should save a broadcast message with correct Map structure', () => {
useMessageStore.getState().saveMessage(broadcastMessage1);
const state = useMessageStore.getState();
const channelId = broadcastMessage1.channel;
expect(state.messages.broadcast.has(channelId)).toBe(true);
const channelLog = state.messages.broadcast.get(channelId);
expect(channelLog).toBeInstanceOf(Map);
expect(channelLog?.has(broadcastMessage1.messageId)).toBe(true);
expect(channelLog?.get(broadcastMessage1.messageId)).toEqual(broadcastMessage1);
});
it('should save multiple messages correctly', () => {
useMessageStore.getState().saveMessage(directMessageToOther1);
useMessageStore.getState().saveMessage(directMessageFromOther1);
useMessageStore.getState().saveMessage(broadcastMessage1);
const state = useMessageStore.getState();
const convId1 = getConversationId(myNodeNum, otherNodeNum1);
expect(state.messages.direct.get(convId1)?.get(directMessageToOther1.messageId)).toEqual(directMessageToOther1);
expect(state.messages.direct.get(convId1)?.get(directMessageFromOther1.messageId)).toEqual(directMessageFromOther1);
const channelId = broadcastMessage1.channel;
expect(state.messages.broadcast.get(channelId)?.get(broadcastMessage1.messageId)).toEqual(broadcastMessage1);
});
});
describe('getMessages', () => {
beforeEach(() => {
useMessageStore.getState().setNodeNum(myNodeNum);
useMessageStore.getState().saveMessage(directMessageToOther1);
useMessageStore.getState().saveMessage(directMessageFromOther1);
useMessageStore.getState().saveMessage(directMessageToOther2);
useMessageStore.getState().saveMessage(broadcastMessage1);
useMessageStore.getState().saveMessage(broadcastMessage2);
});
it('should return broadcast messages for a channel, sorted by date', () => {
const messages = useMessageStore.getState().getMessages({
type: MessageType.Broadcast,
channelId: broadcastChannel
});
expect(messages).toHaveLength(2);
expect(messages[0]).toEqual(broadcastMessage1);
expect(messages[1]).toEqual(broadcastMessage2);
});
it('should return empty array for broadcast if channel has no messages', () => {
const messages = useMessageStore.getState().getMessages({
type: MessageType.Broadcast,
channelId: Types.ChannelNumber.Channel1
});
expect(messages).toEqual([]);
});
it('should return combined direct messages for a specific chat pair, sorted by date', () => {
const messages = useMessageStore.getState().getMessages({
type: MessageType.Direct,
nodeA: myNodeNum,
nodeB: otherNodeNum1
});
expect(messages).toHaveLength(2);
expect(messages[0]).toEqual(directMessageToOther1);
expect(messages[1]).toEqual(directMessageFromOther1);
});
it('should return only relevant direct messages for a different chat pair', () => {
const messages = useMessageStore.getState().getMessages({
type: MessageType.Direct,
nodeA: myNodeNum,
nodeB: otherNodeNum2
});
expect(messages).toHaveLength(1);
expect(messages[0]).toEqual(directMessageToOther2);
});
it('should return empty array for direct chat if no messages exist', () => {
const messages = useMessageStore.getState().getMessages({
type: MessageType.Direct,
nodeA: myNodeNum,
nodeB: 999
});
expect(messages).toEqual([]);
});
});
describe('setMessageState', () => {
beforeEach(() => {
useMessageStore.getState().setNodeNum(myNodeNum);
useMessageStore.getState().saveMessage(directMessageToOther1);
useMessageStore.getState().saveMessage(directMessageFromOther1);
useMessageStore.getState().saveMessage(broadcastMessage1);
});
it('should update state for a direct message', () => {
useMessageStore.getState().setMessageState({
type: MessageType.Direct,
nodeA: directMessageToOther1.from,
nodeB: directMessageToOther1.to,
messageId: directMessageToOther1.messageId,
newState: MessageState.Ack,
});
const conversationId = getConversationId(directMessageToOther1.from, directMessageToOther1.to);
const message = useMessageStore.getState().messages.direct.get(conversationId)?.get(directMessageToOther1.messageId);
expect(message?.state).toBe(MessageState.Ack);
});
it('should update state for another direct message in the same conversation', () => {
useMessageStore.getState().setMessageState({
type: MessageType.Direct,
nodeA: directMessageFromOther1.from,
nodeB: directMessageFromOther1.to,
messageId: directMessageFromOther1.messageId,
newState: MessageState.Failed,
});
const conversationId = getConversationId(directMessageFromOther1.from, directMessageFromOther1.to);
const message = useMessageStore.getState().messages.direct.get(conversationId)?.get(directMessageFromOther1.messageId);
expect(message?.state).toBe(MessageState.Failed);
});
it('should update state for a broadcast message', () => {
useMessageStore.getState().setMessageState({
type: MessageType.Broadcast,
channelId: broadcastChannel,
messageId: broadcastMessage1.messageId,
newState: MessageState.Ack,
});
const message = useMessageStore.getState().messages.broadcast.get(broadcastChannel)?.get(broadcastMessage1.messageId);
expect(message?.state).toBe(MessageState.Ack);
});
it('should warn if message is not found (direct)', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
useMessageStore.getState().setMessageState({
type: MessageType.Direct,
nodeA: myNodeNum,
nodeB: otherNodeNum1,
messageId: 999,
newState: MessageState.Ack,
});
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Message or conversation/channel not found for state update'));
warnSpy.mockRestore();
});
it('should warn if message is not found (broadcast)', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
useMessageStore.getState().setMessageState({
type: MessageType.Broadcast,
channelId: broadcastChannel,
messageId: 999,
newState: MessageState.Ack,
});
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Message or conversation/channel not found for state update'));
warnSpy.mockRestore();
});
it('should warn if conversation/channel is not found', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
useMessageStore.getState().setMessageState({
type: MessageType.Direct,
nodeA: myNodeNum,
nodeB: 998,
messageId: 101,
newState: MessageState.Ack,
});
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Message or conversation/channel not found for state update'));
warnSpy.mockRestore();
});
});
describe('clearMessageByMessageId', () => {
const extraDirectMessageId = 1011;
beforeEach(() => {
useMessageStore.getState().setNodeNum(myNodeNum);
useMessageStore.getState().saveMessage(directMessageToOther1);
useMessageStore.getState().saveMessage(directMessageFromOther1);
useMessageStore.getState().saveMessage(broadcastMessage1);
useMessageStore.getState().saveMessage({ ...directMessageToOther1, messageId: extraDirectMessageId, date: Date.now() + 50 });
});
it('should delete a specific direct message', () => {
const messageIdToDelete = directMessageToOther1.messageId;
const nodeA = directMessageToOther1.from;
const nodeB = directMessageToOther1.to;
const conversationId = getConversationId(nodeA, nodeB);
useMessageStore.getState().clearMessageByMessageId({
type: MessageType.Direct,
nodeA: nodeA,
nodeB: nodeB,
messageId: messageIdToDelete
});
const state = useMessageStore.getState();
const conversationLog = state.messages.direct.get(conversationId);
expect(conversationLog?.has(messageIdToDelete)).toBe(false);
expect(conversationLog?.has(extraDirectMessageId)).toBe(true);
expect(conversationLog?.has(directMessageFromOther1.messageId)).toBe(true);
expect(state.messages.direct.has(conversationId)).toBe(true);
});
it('should delete another specific direct message', () => {
const messageIdToDelete = directMessageFromOther1.messageId;
const nodeA = directMessageFromOther1.from;
const nodeB = directMessageFromOther1.to;
const conversationId = getConversationId(nodeA, nodeB);
useMessageStore.getState().clearMessageByMessageId({
type: MessageType.Direct,
nodeA: nodeA,
nodeB: nodeB,
messageId: messageIdToDelete
});
const state = useMessageStore.getState();
const conversationLog = state.messages.direct.get(conversationId);
expect(conversationLog?.has(messageIdToDelete)).toBe(false);
expect(conversationLog?.has(directMessageToOther1.messageId)).toBe(true);
expect(conversationLog?.has(extraDirectMessageId)).toBe(true);
});
it('should delete a specific broadcast message', () => {
const messageIdToDelete = broadcastMessage1.messageId;
const channelId = broadcastMessage1.channel;
useMessageStore.getState().clearMessageByMessageId({
type: MessageType.Broadcast,
channelId: channelId,
messageId: messageIdToDelete
});
const state = useMessageStore.getState();
expect(state.messages.broadcast.get(channelId)?.get(messageIdToDelete)).toBeUndefined();
});
it('should clean up empty conversation/channel Maps', () => {
const directConvId = getConversationId(directMessageFromOther1.from, directMessageFromOther1.to);
const broadcastChanId = broadcastMessage1.channel;
useMessageStore.getState().clearMessageByMessageId({ type: MessageType.Direct, nodeA: directMessageToOther1.from, nodeB: directMessageToOther1.to, messageId: directMessageToOther1.messageId });
useMessageStore.getState().clearMessageByMessageId({ type: MessageType.Direct, nodeA: directMessageFromOther1.from, nodeB: directMessageFromOther1.to, messageId: directMessageFromOther1.messageId });
useMessageStore.getState().clearMessageByMessageId({ type: MessageType.Direct, nodeA: directMessageToOther1.from, nodeB: directMessageToOther1.to, messageId: extraDirectMessageId });
expect(useMessageStore.getState().messages.direct.has(directConvId)).toBe(false);
useMessageStore.getState().clearMessageByMessageId({ type: MessageType.Broadcast, channelId: broadcastChanId, messageId: broadcastMessage1.messageId });
expect(useMessageStore.getState().messages.broadcast.has(broadcastChanId)).toBe(false);
});
it('should not error when trying to delete non-existent message', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
const conversationId = getConversationId(myNodeNum, otherNodeNum1);
expect(() => {
useMessageStore.getState().clearMessageByMessageId({
type: MessageType.Direct,
nodeA: myNodeNum,
nodeB: otherNodeNum1,
messageId: 9999
});
}).not.toThrow();
const state = useMessageStore.getState();
const conversationLog = state.messages.direct.get(conversationId);
expect(conversationLog?.size).toBe(3); // 101, 102, 1011
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('not found in direct chat'));
warnSpy.mockRestore();
});
it('should not error when trying to delete from non-existent conversation/channel', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
expect(() => {
useMessageStore.getState().clearMessageByMessageId({
type: MessageType.Direct,
nodeA: myNodeNum,
nodeB: 9998,
messageId: 101
});
}).not.toThrow();
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Message entry"));
expect(warnSpy).toHaveBeenCalledTimes(1);
warnSpy.mockRestore();
});
});
describe('Drafts', () => {
const draftKeyDirect = otherNodeNum1;
const draftKeyBroadcast = broadcastChannel;
const draftMessage = 'This is a draft';
it('should set and get a draft for direct chat', () => {
useMessageStore.getState().setDraft(draftKeyDirect, draftMessage);
expect(useMessageStore.getState().draft.get(draftKeyDirect)).toBe(draftMessage);
expect(useMessageStore.getState().getDraft(draftKeyDirect)).toBe(draftMessage);
});
it('should set and get a draft for broadcast chat', () => {
useMessageStore.getState().setDraft(draftKeyBroadcast, draftMessage);
expect(useMessageStore.getState().draft.get(draftKeyBroadcast)).toBe(draftMessage);
expect(useMessageStore.getState().getDraft(draftKeyBroadcast)).toBe(draftMessage);
});
it('should return empty string for non-existent draft', () => {
expect(useMessageStore.getState().getDraft(999)).toBe('');
});
it('should clear a draft', () => {
useMessageStore.getState().setDraft(draftKeyDirect, draftMessage);
expect(useMessageStore.getState().draft.has(draftKeyDirect)).toBe(true);
useMessageStore.getState().clearDraft(draftKeyDirect);
expect(useMessageStore.getState().draft.has(draftKeyDirect)).toBe(false);
expect(useMessageStore.getState().getDraft(draftKeyDirect)).toBe('');
});
});
describe('deleteAllMessages', () => {
it('should clear all direct and broadcast messages, leaving empty Maps', () => {
useMessageStore.getState().saveMessage(directMessageToOther1);
useMessageStore.getState().saveMessage(broadcastMessage1);
expect(useMessageStore.getState().messages.direct.size).toBeGreaterThan(0);
expect(useMessageStore.getState().messages.broadcast.size).toBeGreaterThan(0);
useMessageStore.getState().deleteAllMessages();
const state = useMessageStore.getState();
expect(state.messages.direct).toBeInstanceOf(Map);
expect(state.messages.direct.size).toBe(0);
expect(state.messages.broadcast).toBeInstanceOf(Map);
expect(state.messages.broadcast.size).toBe(0);
});
});
});

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

@ -1,70 +0,0 @@
import { Types } from "@meshtastic/core";
import { MessageState, MessageType } from "@core/stores/messageStore/index.ts";
type NodeNum = number;
type MessageId = number;
type ChannelId = Types.ChannelNumber;
type ConversationId = string;
type MessageLogMap = Map<MessageId, Message>;
interface MessageBase {
channel: Types.ChannelNumber;
to: number;
from: number;
date: number;
messageId: number;
state: MessageState;
message: string;
}
interface GenericMessage<T extends MessageType> extends MessageBase {
type: T;
}
type Message = GenericMessage<MessageType.Direct> | GenericMessage<MessageType.Broadcast>;
type GetMessagesParams =
| { type: MessageType.Direct; nodeA: NodeNum; nodeB: NodeNum }
| { type: MessageType.Broadcast; channelId: ChannelId };
type SetMessageStateParams =
| {
type: MessageType.Direct;
nodeA: NodeNum;
nodeB: NodeNum;
messageId: MessageId; // ID of the message within that chat
newState?: MessageState; // Optional new state, defaults to Ack
}
| {
type: MessageType.Broadcast;
channelId: ChannelId;
messageId: MessageId;
newState?: MessageState; // Optional new state, defaults to Ack
};
type ClearMessageParams =
| {
type: MessageType.Direct;
nodeA: NodeNum;
nodeB: NodeNum;
messageId: MessageId;
}
| {
type: MessageType.Broadcast;
channelId: ChannelId;
messageId: MessageId;
};
export type {
Message,
ConversationId,
NodeNum,
MessageLogMap,
ChannelId,
MessageId,
GetMessagesParams,
SetMessageStateParams,
ClearMessageParams,
}

37
src/core/stores/sidebarStore.tsx

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

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

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

40
src/core/subscriptions.ts

@ -1,16 +1,15 @@
import type { Device } from "@core/stores/deviceStore.ts";
import { MeshDevice, Protobuf } from "@meshtastic/core";
import { MessageState, MessageType, type MessageStore } from "./stores/messageStore/index.ts";
import PacketToMessageDTO from "@core/dto/PacketToMessageDTO.ts";
import NodeInfoFactory from "@core/dto/NodeNumToNodeInfoDTO.ts";
export const subscribeAll = (
device: Device,
connection: MeshDevice,
messageStore: MessageStore
) => {
let myNodeNum = 0;
// onLogEvent
// onMeshHeartbeat
connection.events.onDeviceMetadataPacket.subscribe((metadataPacket) => {
device.addMetadata(metadataPacket.from, metadataPacket.data);
});
@ -52,7 +51,6 @@ export const subscribeAll = (
connection.events.onMyNodeInfo.subscribe((nodeInfo) => {
device.setHardware(nodeInfo);
messageStore.setNodeNum(nodeInfo.myNodeNum);
myNodeNum = nodeInfo.myNodeNum;
});
@ -65,8 +63,10 @@ export const subscribeAll = (
});
connection.events.onNodeInfoPacket.subscribe((nodeInfo) => {
const nodeWithUser = NodeInfoFactory.ensureDefaultUser(nodeInfo);
device.addNodeInfo(nodeWithUser);
// toast(`New Node Discovered: ${nodeInfo.user?.shortName ?? "UNK"}`, {
// icon: "🔎"
// });
device.addNodeInfo(nodeInfo);
});
connection.events.onChannelPacket.subscribe((channel) => {
@ -81,22 +81,10 @@ export const subscribeAll = (
connection.events.onMessagePacket.subscribe((messagePacket) => {
// incoming and outgoing messages are handled by this event listener
console.log("Message Packet", messagePacket);
const dto = new PacketToMessageDTO(messagePacket, myNodeNum);
const message = dto.toMessage();
messageStore.saveMessage(message);
if (message.type == MessageType.Direct) {
if (message.to === myNodeNum) {
device.incrementUnread(messagePacket.from);
}
} else if (message.type == MessageType.Broadcast) {
if (message.from !== myNodeNum) {
device.incrementUnread(message.channel);
}
}
device.addMessage({
...messagePacket,
state: messagePacket.from !== myNodeNum ? "ack" : "waiting",
});
});
connection.events.onTraceRoutePacket.subscribe((traceRoutePacket) => {
@ -117,13 +105,13 @@ export const subscribeAll = (
});
});
connection.events.onQueueStatus.subscribe((queueStatus) => {
device.setQueueStatus(queueStatus);
});
connection.events.onRoutingPacket.subscribe((routingPacket) => {
if (routingPacket.data.variant.case === "errorReason") {
switch (routingPacket.data.variant.value) {
case Protobuf.Mesh.Routing_Error.MAX_RETRANSMIT:
console.error(`Routing Error: ${routingPacket.data.variant.value}`);
break;
case Protobuf.Mesh.Routing_Error.NO_CHANNEL:
console.error(`Routing Error: ${routingPacket.data.variant.value}`);
device.setNodeError(routingPacket.from, Protobuf.Mesh.Routing_Error[routingPacket?.data?.variant?.value]);

40
src/core/utils/string.ts

@ -29,43 +29,3 @@ export function formatQuantity(
return `${numberFormat.format(value)} ${word}`;
}
export interface LengthValidationResult {
isValid: boolean;
currentLength: number | null;
}
export function validateMaxByteLength(value: string | null | undefined, maxByteLength: number): LengthValidationResult {
// Ensure maxByteLength is valid
if (typeof maxByteLength !== 'number' || !Number.isInteger(maxByteLength) || maxByteLength < 0) {
console.warn('validateMaxByteLength: maxByteLength must be a non-negative integer.');
return { isValid: false, currentLength: null }; // Cannot validate with invalid limit
}
// Handle null or undefined input values
if (value === null || value === undefined) {
return { isValid: false, currentLength: null };
}
// Check for TextEncoder availability
if (typeof TextEncoder === 'undefined') {
console.error('validateMaxByteLength: TextEncoder API is not available in this environment.');
return { isValid: false, currentLength: null }; // Cannot determine byte length
}
try {
// Encode the string to UTF-8 bytes and get the length
const encoder = new TextEncoder();
const currentLength = encoder.encode(value).length;
// Perform the byte length check
const isValid = currentLength <= maxByteLength;
// Return the result object
return { isValid, currentLength };
} catch (error) {
// Handle potential errors during encoding
console.error('validateMaxByteLength: Error encoding string:', error);
return { isValid: false, currentLength: null }; // Encoding failed
}
}

1
src/index.css

@ -25,7 +25,6 @@
--brightness-hover: var(--brightnessHover);
--brightness-press: var(--brightnessPress);
--brightness-disabled: var(--brightnessDisabled);
--sidebar-width: @apply w-50 lg:w-64;
}
[data-theme="light"] {

13
src/pages/Channels.tsx

@ -17,8 +17,8 @@ export const getChannelName = (channel: Protobuf.Channel.Channel) =>
channel.settings?.name.length
? channel.settings?.name
: channel.index === 0
? "Primary"
: `Ch ${channel.index}`;
? "Primary"
: `Ch ${channel.index}`;
const ChannelsPage = () => {
const { channels, setDialogOpen } = useDevice();
@ -31,20 +31,19 @@ const ChannelsPage = () => {
return (
<>
<Sidebar />
<PageLayout
leftBar={<Sidebar />}
label={`Channel: ${currentChannel ? getChannelName(currentChannel) : "Loading..."
}`}
label={`Channel: ${
currentChannel ? getChannelName(currentChannel) : "Loading..."
}`}
actions={[
{
key: "search",
icon: ImportIcon,
onClick() {
setDialogOpen("import", true);
},
},
{
key: "import",
icon: QrCodeIcon,
onClick() {
setDialogOpen("QR", true);

2
src/pages/Config/DeviceConfig.tsx

@ -52,7 +52,7 @@ export const DeviceConfig = () => {
return (
<Tabs defaultValue="Device">
<TabsList className="dark:bg-slate-700">
<TabsList className="dark:bg-slate-800">
{tabs.map((tab) => (
<TabsTrigger
key={tab.label}

21
src/pages/Config/index.tsx

@ -3,12 +3,12 @@ import { useDevice } from "@core/stores/deviceStore.ts";
import { PageLayout } from "@components/PageLayout.tsx";
import { Sidebar } from "@components/Sidebar.tsx";
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
import { SidebarButton } from "../../components/UI/Sidebar/SidebarButton.tsx";
import { SidebarButton } from "@components/UI/Sidebar/sidebarButton.tsx";
import { useToast } from "@core/hooks/useToast.ts";
import { DeviceConfig } from "@pages/Config/DeviceConfig.tsx";
import { ModuleConfig } from "@pages/Config/ModuleConfig.tsx";
import { BoxesIcon, SaveIcon, SaveOff, SettingsIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { useState } from "react";
const ConfigPage = () => {
const { workingConfig, workingModuleConfig, connection } = useDevice();
@ -51,14 +51,11 @@ const ConfigPage = () => {
description:
`The configuration change ${moduleConfig.payloadVariant.case} has been saved.`,
})
)
),
);
setIsSaving(false);
}
await connection?.commitEditSettings();
} catch (_error) {
toast({
title: "Error Saving Config",
@ -69,10 +66,10 @@ const ConfigPage = () => {
}
};
const leftSidebar = useMemo(
() => (
return (
<>
<Sidebar>
<SidebarSection label="Modules">
<SidebarSection label="Config Sections">
<SidebarButton
label="Radio Config"
active={activeConfigSection === "device"}
@ -87,20 +84,12 @@ const ConfigPage = () => {
/>
</SidebarSection>
</Sidebar>
), [])
return (
<>
<PageLayout
contentClassName="overflow-auto"
leftBar={leftSidebar}
label={activeConfigSection === "device"
? "Radio Config"
: "Module Config"}
actions={[
{
key: "save",
icon: isError ? SaveOff : SaveIcon,
isLoading: isSaving,
disabled: isSaving,

2
src/pages/Dashboard/index.tsx

@ -99,7 +99,7 @@ export const Dashboard = () => {
<Heading as="h3">No Devices</Heading>
<Subtle>Connect at least one device to get started</Subtle>
<Button
className="gap-2"
className="gap-2 dark:bg-white dark:text-slate-900 dark:hover:text-slate-100"
variant="default"
onClick={() => setConnectDialogOpen(true)}
>

265
src/pages/Map/FilterControl.tsx

@ -1,265 +0,0 @@
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@components/UI/Popover.tsx";
import { FunnelIcon } from "lucide-react";
import { Checkbox } from "@components/UI/Checkbox/index.tsx";
import { Slider } from "@components/UI/Slider.tsx";
import { ScrollArea } from "@components/UI/ScrollArea.tsx";
import {
Accordion,
AccordionContent,
AccordionHeader,
AccordionItem,
AccordionTrigger,
} from "@components/UI/Accordion.tsx";
import type {
FilterConfig,
FilterValueMap,
} from "@core/hooks/useNodeFilters.ts";
import { cn } from "@core/utils/cn.ts";
import { TimeAgo } from "@components/generic/TimeAgo.tsx";
interface FilterControlProps {
groupedFilterConfigs: Record<string, FilterConfig[]>;
values: FilterValueMap;
onChange: <K extends keyof FilterValueMap>(
key: K,
value: FilterValueMap[K],
) => void;
resetFilters: () => void;
isDirty: boolean;
children?: React.ReactNode;
}
export function FilterControl(
{ groupedFilterConfigs, values, onChange, resetFilters, isDirty, children }:
FilterControlProps,
) {
return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
"fixed bottom-17 right-2 px-1 py-1 rounded shadow-md",
isDirty
? " text-slate-100 bg-green-600 hover:bg-green-700 hover:text-slate-200 active:bg-green-800"
: "text-slate-600 bg-slate-100 hover:bg-slate-200 hover:text-slate-700 active:bg-slate-300",
)}
aria-label="Filter"
>
<FunnelIcon />
</button>
</PopoverTrigger>
<PopoverContent
side="bottom"
align="end"
sideOffset={12}
className="dark:bg-slate-100 dark:border-slate-300"
>
<div className="space-y-4">
<Accordion
className="AccordionRoot"
type="single"
defaultValue={Object.entries(groupedFilterConfigs)[0][0]}
collapsible
>
{Object.entries(groupedFilterConfigs).map((
[groupName, groupConfigs],
) => (
<AccordionItem key={groupName} value={groupName}>
<AccordionHeader>
<AccordionTrigger className="w-full text-left font-bold text-sm px-1 py-2">
{groupName}
</AccordionTrigger>
</AccordionHeader>
<AccordionContent className="px-1 pb-4 pt-2 space-y-3">
{groupConfigs.map((cfg) => {
const val = values[cfg.key];
switch (cfg.type) {
case "boolean":
if (typeof val !== "boolean") return null;
return (
<Checkbox
key={cfg.key}
checked={val}
onChange={(v) => onChange(cfg.key, v)}
className="pb-1"
labelClassName="dark:text-slate-900"
>
{cfg.label}
</Checkbox>
);
case "range": {
if (
!Array.isArray(val) ||
val.length !== 2 ||
typeof val[0] !== "number" ||
typeof val[1] !== "number"
) {
return null;
}
const [min, max] = val;
const [lo, hi] = cfg.bounds;
let formattedMin = null;
let formattedMax = null;
// Some filters require special formatting for min/max values
if (cfg.key == "battery" && min == hi) {
formattedMin = "Charging";
}
if (cfg.key == "battery" && max == hi) {
formattedMax = "Charging";
}
if (cfg.key == "hopRange" && min == lo) {
formattedMin = "Direct";
}
if (cfg.key == "lastHeard") {
formattedMin = (
<>
<br />
{min === lo ? "now" : (
<TimeAgo
timestamp={Date.now() - min * 1000}
/>
)}
</>
);
formattedMax = (
<>
{max === hi ? ">" : ""}
<TimeAgo timestamp={Date.now() - max * 1000} />
</>
);
}
return (
<div key={cfg.key} className="space-y-2">
<label className="block text-sm font-medium">
{cfg.label}:{" "}
{min === max ? formattedMin ?? min : (
<>
{formattedMin ?? min} {formattedMax ?? max}
</>
)}
</label>
<Slider
value={[min, max]}
min={lo}
max={hi}
step={Number.isInteger(lo) ? 1 : 0.1}
onValueChange={(newRange) => {
const [newMin, newMax] = newRange;
onChange(cfg.key, [newMin, newMax]);
}}
className="w-full pb-3"
trackClassName="h-1 bg-slate-200 dark:bg-slate-700"
rangeClassName="bg-blue-500"
thumbClassName="w-3 h-3 bg-white border border-slate-400 dark:border-slate-600"
aria-label={`Slider - ${cfg.label}`}
/>
</div>
);
}
case "multi": {
const safeArray = (() => {
if (!Array.isArray(val)) return [];
return val.filter((x): x is string =>
typeof x === "string"
);
})();
const allSelected = cfg.options.length > 0 &&
cfg.options.every((opt) => safeArray.includes(opt));
return (
<ScrollArea className="h-64 border rounded-md">
<div
key={cfg.key}
className="space-y-2 px-2 py-3"
>
<button
type="button"
className="w-full py-1 shadow-sm hover:shadow-md bg-slate-600 text-white rounded text-sm hover:text-slate-100 hover:bg-slate-700 active:bg-slate-800"
onClick={() =>
onChange(
cfg.key,
allSelected ? [] : [...cfg.options],
)}
>
{allSelected ? "Uncheck All" : "Check All"}
</button>
{cfg.options.map((opt) => (
<Checkbox
key={opt.replace(/ /g, "_")}
checked={safeArray.includes(opt)}
onChange={(checked) =>
onChange(
cfg.key,
checked
? [...safeArray, opt]
: safeArray.filter((s) => s !== opt),
)}
>
{opt}
</Checkbox>
))}
</div>
</ScrollArea>
);
}
case "search":
if (typeof val !== "string") return null;
return (
<div
key={`${cfg.key}_div`}
className="flex flex-col space-y-1 pb-2"
>
<label
htmlFor={cfg.key}
className="font-medium text-sm"
>
{cfg.label}
</label>
<input
id={cfg.key}
type="text"
value={val}
onChange={(e) =>
onChange(cfg.key, e.target.value)}
placeholder="Search phrase"
className="w-full px-2 py-1 border rounded shadow-sm dark:bg-slate-200 dark:border-slate-600"
/>
</div>
);
default:
return null;
}
})}
</AccordionContent>
</AccordionItem>
))}
</Accordion>
<button
type="button"
onClick={resetFilters}
className="w-full py-1 shadow-sm hover:shadow-md bg-slate-600 text-white rounded text-sm hover:text-slate-100 hover:bg-slate-700 active:bg-slate-800"
>
Reset Filters
</button>
{children && (
<div className="mt-4 border-t pt-4">
{children}
</div>
)}
</div>
</PopoverContent>
</Popover>
);
}

46
src/pages/Map/index.tsx

@ -18,8 +18,6 @@ import {
useMap,
} from "react-map-gl/maplibre";
import MapGl from "react-map-gl/maplibre";
import { useNodeFilters } from "@core/hooks/useNodeFilters.ts";
import { FilterControl } from "@pages/Map/FilterControl.tsx";
type NodePosition = {
latitude: number;
@ -35,7 +33,7 @@ const convertToLatLng = (position: {
});
const MapPage = () => {
const { getNodes, waypoints } = useDevice();
const { nodes, waypoints } = useDevice();
const { theme } = useTheme();
const { default: map } = useMap();
@ -48,33 +46,13 @@ const MapPage = () => {
// Filter out nodes without a valid position
const validNodes = useMemo(
() =>
getNodes(
Array.from(nodes.values()).filter(
(node): node is Protobuf.Mesh.NodeInfo =>
Boolean(node.position?.latitudeI),
),
[getNodes],
[nodes],
);
const {
filters,
defaultState,
onFilterChange,
resetFilters,
filteredNodes,
groupedFilterConfigs,
} = useNodeFilters(validNodes);
const isDirty = useMemo(() => {
return Object.keys(filters).some((key) => {
const a = filters[key];
const b = defaultState[key];
// simple deep‐equal for primitives and [number,number]
return Array.isArray(a) && Array.isArray(b)
? a[0] !== b[0] || a[1] !== b[1]
: a !== b;
});
}, [filters, defaultState]);
const handleMarkerClick = useCallback(
(node: Protobuf.Mesh.NodeInfo, event: { originalEvent: MouseEvent }) => {
event?.originalEvent?.stopPropagation();
@ -128,12 +106,12 @@ const MapPage = () => {
if (center) {
map.easeTo(center);
}
}, [filteredNodes, map]);
}, [validNodes, map]);
// Generate all markers
const markers = useMemo(
() =>
filteredNodes.map((node) => {
validNodes.map((node) => {
const position = convertToLatLng(node.position);
return (
<Marker
@ -150,7 +128,7 @@ const MapPage = () => {
</Marker>
);
}),
[filteredNodes, handleMarkerClick],
[validNodes, handleMarkerClick],
);
useEffect(() => {
@ -161,7 +139,8 @@ const MapPage = () => {
return (
<>
<PageLayout label="Map" noPadding actions={[]} leftBar={<Sidebar />}>
<Sidebar />
<PageLayout label="Map" noPadding actions={[]}>
<MapGl
mapStyle="https://raw.githubusercontent.com/hc-oss/maplibre-gl-styles/master/styles/osm-mapnik/v8/default.json"
attributionControl={false}
@ -212,21 +191,12 @@ const MapPage = () => {
longitude={convertToLatLng(selectedNode.position).longitude}
latitude={convertToLatLng(selectedNode.position).latitude}
onClose={() => setSelectedNode(null)}
className="w-full"
>
<NodeDetail node={selectedNode} />
</Popup>
)
: null}
</MapGl>
<FilterControl
groupedFilterConfigs={groupedFilterConfigs}
values={filters}
onChange={onFilterChange}
resetFilters={resetFilters}
isDirty={isDirty}
/>
</PageLayout>
</>
);

74
src/pages/Messages.test.tsx

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

322
src/pages/Messages.tsx

@ -1,221 +1,163 @@
import { useAppStore } from "../core/stores/appStore.ts";
import { ChannelChat } from "@components/PageComponents/Messages/ChannelChat.tsx";
import { PageLayout } from "@components/PageLayout.tsx";
import { Sidebar } from "@components/Sidebar.tsx";
import { Avatar } from "@components/UI/Avatar.tsx";
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.tsx";
import { SidebarButton } from "@components/UI/Sidebar/sidebarButton.tsx";
import { useToast } from "@core/hooks/useToast.ts";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf, Types } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { getChannelName } from "@pages/Channels.tsx";
import { HashIcon, LockIcon, LockOpenIcon } from "lucide-react";
import { useCallback, useDeferredValue, useMemo, useState } from "react";
import { useState } from "react";
import { MessageInput } from "@components/PageComponents/Messages/MessageInput.tsx";
import { cn } from "@core/utils/cn.ts";
import { MessageState, MessageType, useMessageStore } from "@core/stores/messageStore/index.ts";
import { useSidebar } from "@core/stores/sidebarStore.tsx";
import { Input } from "@components/UI/Input.tsx";
type NodeInfoWithUnread = Protobuf.Mesh.NodeInfo & { unreadCount: number };
export const MessagesPage = () => {
const { channels, getNodes, getNode, hasNodeError, unreadCounts, resetUnread, connection } = useDevice();
const { getMyNodeNum, getMessages, setActiveChat, chatType, activeChat, setChatType, setMessageState, } = useMessageStore()
const { toast } = useToast();
const { isCollapsed } = useSidebar()
const { channels, nodes, hardware, messages, hasNodeError } = useDevice();
const { activeChat, chatType, setActiveChat, setChatType } = useAppStore();
const [searchTerm, setSearchTerm] = useState<string>("");
const deferredSearch = useDeferredValue(searchTerm);
const filteredNodes = (): NodeInfoWithUnread[] => {
const lowerCaseSearchTerm = deferredSearch.toLowerCase();
return getNodes(node => {
const longName = node.user?.longName?.toLowerCase() ?? '';
const shortName = node.user?.shortName?.toLowerCase() ?? '';
return longName.includes(lowerCaseSearchTerm) || shortName.includes(lowerCaseSearchTerm)
})
.map((node) => ({
...node,
unreadCount: unreadCounts.get(node.num) ?? 0,
}))
.sort((a, b) => b.unreadCount - a.unreadCount);
}
const filteredNodes = Array.from(nodes.values()).filter((node) => {
if (node.num === hardware.myNodeNum) return false;
const nodeName = node.user?.longName ?? `!${numberToHexUnpadded(node.num)}`;
return nodeName.toLowerCase().includes(searchTerm.toLowerCase());
});
const allChannels = Array.from(channels.values());
const filteredChannels = allChannels.filter(
(ch) => ch.role !== Protobuf.Channel.Channel_Role.DISABLED,
);
const currentChannel = channels.get(activeChat);
const otherNode = getNode(activeChat);
const isDirect = chatType === MessageType.Direct;
const isBroadcast = chatType === MessageType.Broadcast;
const sendText = useCallback(async (message: string) => {
const isDirect = chatType === MessageType.Direct;
const toValue = isDirect ? activeChat : MessageType.Broadcast;
const channelValue = isDirect ? Types.ChannelNumber.Primary : activeChat ?? 0;
console.log(`Sending message: "${message}" to: ${toValue}, channel: ${channelValue}, type: ${chatType}`);
let messageId: number | undefined;
try {
messageId = await connection?.sendText(message, toValue, true, channelValue);
if (messageId !== undefined) {
if (chatType === MessageType.Broadcast) {
setMessageState({ type: chatType, channelId: channelValue, messageId, newState: MessageState.Ack });
} else {
setMessageState({ type: chatType, nodeA: getMyNodeNum(), nodeB: activeChat, messageId, newState: MessageState.Ack });
}
} else {
console.warn("sendText completed but messageId is undefined");
}
// deno-lint-ignore no-explicit-any
} catch (e: any) {
console.error("Failed to send message:", e);
// Note: messageId might be undefined here if the error occurred before it was assigned
if (chatType === MessageType.Broadcast) {
const failedId = messageId ?? `failed-${Date.now()}`;
setMessageState({ type: chatType, channelId: channelValue, messageId: failedId, newState: MessageState.Failed });
} else { // MessageType.Direct
const failedId = messageId ?? `failed-${Date.now()}`;
setMessageState({ type: chatType, nodeA: getMyNodeNum(), nodeB: activeChat, messageId: failedId, newState: MessageState.Failed });
}
}
}, [activeChat, chatType, connection, getMyNodeNum, setMessageState]);
const renderChatContent = () => {
switch (chatType) {
case MessageType.Broadcast:
return (
<ChannelChat
messages={getMessages({
type: MessageType.Broadcast,
channelId: activeChat ?? 0,
})}
/>
);
case MessageType.Direct:
return (
<ChannelChat
messages={getMessages({ type: MessageType.Direct, nodeA: getMyNodeNum(), nodeB: activeChat })}
/>
);
default:
return (
<div className="flex-1 flex items-center justify-center text-slate-500 p-4">
Select a channel or node to start messaging.
</div>
);
}
}
const { toast } = useToast();
const node = nodes.get(activeChat);
const nodeHex = node?.num ? numberToHexUnpadded(node.num) : "Unknown";
const leftSidebar = useMemo(() => (
<Sidebar>
<SidebarSection label="Channels" className="py-2 px-0">
{filteredChannels?.map((channel) => (
<SidebarButton
key={channel.index}
count={unreadCounts.get(channel.index)}
label={channel.settings?.name || (channel.index === 0 ? "Primary" : `Ch ${channel.index}`)}
active={activeChat === channel.index && chatType === MessageType.Broadcast}
onClick={() => {
setChatType(MessageType.Broadcast);
setActiveChat(channel.index);
resetUnread(channel.index);
}}
>
<HashIcon size={16} className={cn(isCollapsed ? "mr-0 mt-2" : "mr-2")} />
</SidebarButton>
))}
</SidebarSection>
</Sidebar>
), [filteredChannels, unreadCounts, activeChat, chatType, isCollapsed, setActiveChat, setChatType, resetUnread]);
const messageDestination = chatType === "direct" ? activeChat : "broadcast";
const messageChannel = chatType === "direct"
? Types.ChannelNumber.Primary
: activeChat;
const rightSidebar = useMemo(() => (
<SidebarSection label="" className="px-0 flex flex-col h-full overflow-y-auto">
<label className="p-2 block">
<Input
type="text"
placeholder="Search nodes..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
showClearButton={!!searchTerm}
/>
</label>
<div className={cn(
"flex flex-col h-full flex-1 overflow-y-auto gap-2.5 pt-1 ",
)}>
{filteredNodes()?.map((node) => (
<SidebarButton
key={node.num}
preventCollapse={true}
label={node.user?.longName ?? `UNK`}
count={node.unreadCount > 0 ? node.unreadCount : undefined}
active={activeChat === node.num && chatType === MessageType.Direct}
onClick={() => {
setChatType(MessageType.Direct);
setActiveChat(node.num);
resetUnread(node.num);
}}>
<Avatar
text={node.user?.shortName ?? "UNK"}
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 (
<PageLayout
label={`Messages: ${isBroadcast && currentChannel
? getChannelName(currentChannel)
: isDirect && otherNode
? (otherNode.user?.longName ?? "Unknown")
: "Select a Chat"
}`}
rightBar={rightSidebar}
leftBar={leftSidebar}
actions={isDirect && otherNode
? [
{
key: 'encryption',
icon: otherNode.user?.publicKey?.length ? LockIcon : LockOpenIcon,
iconClasses: otherNode.user?.publicKey?.length
? "text-green-600"
: "text-yellow-300",
onClick() {
toast({
title: otherNode.user?.publicKey?.length
? "Chat is using PKI encryption."
: "Chat is using PSK encryption.",
});
},
},
]
: []}
>
<div className="flex flex-1 flex-col overflow-hidden">
{renderChatContent()}
<>
<Sidebar>
<SidebarSection label="Channels">
{filteredChannels.map((channel) => (
<SidebarButton
key={channel.index}
label={channel.settings?.name.length
? channel.settings?.name
: channel.index === 0
? "Primary"
: `Ch ${channel.index}`}
active={activeChat === channel.index && chatType === "broadcast"}
onClick={() => {
setChatType("broadcast");
setActiveChat(channel.index);
}}
element={<HashIcon size={16} className="mr-2" />}
/>
))}
</SidebarSection>
<SidebarSection label="Nodes">
<div className="p-4">
<input
type="text"
placeholder="Search nodes..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full p-2 border border-slate-300 rounded-sm bg-white text-slate-900"
/>
</div>
<div className="flex flex-col gap-4">
{filteredNodes.map((node) => (
<SidebarButton
key={node.num}
label={node.user?.longName ??
`!${numberToHexUnpadded(node.num)}`}
active={activeChat === node.num && chatType === "direct"}
onClick={() => {
setChatType("direct");
setActiveChat(node.num);
}}
element={
<Avatar
text={node.user?.shortName ?? node.num.toString()}
className={cn(hasNodeError(node.num) && "text-red-500")}
showError={hasNodeError(node.num)}
size="sm"
/>
}
/>
))}
</div>
</SidebarSection>
</Sidebar>
<div className="flex flex-col w-full h-full container mx-auto">
<PageLayout
className="flex flex-col h-full"
label={`Messages: ${chatType === "broadcast" && currentChannel
? getChannelName(currentChannel)
: chatType === "direct" && nodes.get(activeChat)
? (nodes.get(activeChat)?.user?.longName ?? nodeHex)
: "Loading..."
}`}
actions={chatType === "direct"
? [
{
icon: nodes.get(activeChat)?.user?.publicKey.length
? LockIcon
: LockOpenIcon,
iconClasses: nodes.get(activeChat)?.user?.publicKey.length
? "text-green-600"
: "text-yellow-300",
onClick() {
const targetNode = nodes.get(activeChat)?.num;
if (targetNode === undefined) return;
toast({
title: nodes.get(activeChat)?.user?.publicKey.length
? "Chat is using PKI encryption."
: "Chat is using PSK encryption.",
});
},
},
]
: []}
>
<div className="flex-1 overflow-y-auto">
{chatType === "broadcast" && currentChannel && (
<div className="flex flex-col h-full">
<div className="flex-1 overflow-y-auto">
<ChannelChat
key={currentChannel.index}
messages={messages.broadcast.get(currentChannel.index)}
/>
</div>
</div>
)}
{chatType === "direct" && node && (
<div className="flex flex-col h-full">
<div className="flex-1 overflow-y-auto">
<ChannelChat
key={node.num}
messages={messages.direct.get(node.num)}
/>
</div>
</div>
)}
</div>
<div className="flex-none dark:bg-slate-900 p-2">
{(isBroadcast || isDirect) ? (
<div className="shrink-0 p-4 w-full dark:bg-slate-900">
<MessageInput
to={isDirect ? activeChat : MessageType.Broadcast}
onSend={sendText}
to={messageDestination}
channel={messageChannel}
maxBytes={200}
/>
) : (
<div className="p-4 text-center text-slate-400 italic">Select a chat to send a message.</div>
)}
</div>
</div>
</PageLayout>
</div>
</PageLayout>
</>
);
};

69
src/pages/Nodes.tsx

@ -1,6 +1,7 @@
import { LocationResponseDialog } from "@app/components/Dialog/LocationResponseDialog.tsx";
import { NodeOptionsDialog } from "@app/components/Dialog/NodeOptionsDialog.tsx";
import { TracerouteResponseDialog } from "@app/components/Dialog/TracerouteResponseDialog.tsx";
import Footer from "@app/components/UI/Footer.tsx";
import { Sidebar } from "@components/Sidebar.tsx";
import { Avatar } from "@components/UI/Avatar.tsx";
import { Mono } from "@components/generic/Mono.tsx";
@ -10,18 +11,30 @@ import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf, type Types } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { LockIcon, LockOpenIcon } from "lucide-react";
import { type JSX, useCallback, useDeferredValue, useEffect, useState } from "react";
import { type JSX, useCallback, useEffect, useState } from "react";
import { base16 } from "rfc4648";
import { Input } from "@components/UI/Input.tsx";
import { PageLayout } from "@components/PageLayout.tsx";
export interface DeleteNoteDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
function shortNameFromNode(
node: ReturnType<useDevice>["nodes"][number],
): string {
const shortNameOfNode = node.user?.shortName ??
(node.user?.macaddr
? `${
base16
.stringify(node.user?.macaddr.subarray(4, 6) ?? [])
.toLowerCase()
}`
: `${numberToHexUnpadded(node.num).slice(-4)}`);
return String(shortNameOfNode);
}
const NodesPage = (): JSX.Element => {
const { getNodes, hardware, connection } = useDevice();
const { nodes, hardware, connection } = useDevice();
const [selectedNode, setSelectedNode] = useState<
Protobuf.Mesh.NodeInfo | undefined
>(undefined);
@ -32,16 +45,12 @@ const NodesPage = (): JSX.Element => {
Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery> | undefined
>();
const [searchTerm, setSearchTerm] = useState<string>("");
const deferredSearch = useDeferredValue(searchTerm);
const filteredNodes = getNodes(node => {
if (!node.user) return false;
const lowerCaseSearchTerm = deferredSearch.toLowerCase();
return (
node.user?.longName?.toLowerCase().includes(lowerCaseSearchTerm) ||
node.user?.shortName?.toLowerCase().includes(lowerCaseSearchTerm)
);
})
const filteredNodes = Array.from(nodes.values()).filter((node) => {
if (node.num === hardware.myNodeNum) return false;
const nodeName = node.user?.longName ?? `!${numberToHexUnpadded(node.num)}`;
return nodeName.toLowerCase().includes(searchTerm.toLowerCase());
});
useEffect(() => {
if (!connection) return;
@ -76,17 +85,18 @@ const NodesPage = (): JSX.Element => {
return (
<>
<PageLayout label="" leftBar={<Sidebar />} className="flex flex-col w-full">
<div className="p-2">
<Input
<Sidebar />
<div className="flex flex-col w-full">
<div className="p-4">
<input
type="text"
placeholder="Search nodes..."
value={searchTerm}
className="bg-transparent"
showClearButton={!!searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full p-2 border border-slate-300 rounded-sm bg-white text-slate-900"
/>
</div>
<div className="overflow-y-auto">
<div className="overflow-y-auto h-full">
<Table
headings={[
{ title: "", type: "blank", sortable: false },
@ -100,7 +110,7 @@ const NodesPage = (): JSX.Element => {
]}
rows={filteredNodes.map((node) => [
<div key={node.num}>
<Avatar text={node.user?.shortName ?? "UNK "} />
<Avatar text={shortNameFromNode(node)} />
</div>,
<h1
key="longName"
@ -108,17 +118,25 @@ const NodesPage = (): JSX.Element => {
onKeyUp={(evt) => {
evt.key === "Enter" && setSelectedNode(node);
}}
className="cursor-pointer underline ml-2 whitespace-break-spaces"
className="cursor-pointer underline"
tabIndex={0}
role="button"
>
{node.user?.longName ?? numberToHexUnpadded(node.num)}
{node.user?.longName ??
(node.user?.macaddr
? `Meshtastic ${
base16
.stringify(node.user?.macaddr.subarray(4, 6) ?? [])
.toLowerCase()
}`
: `!${numberToHexUnpadded(node.num)}`)}
</h1>,
<Mono key="hops" className="w-16">
<Mono key="hops">
{node.lastHeard !== 0
? node.viaMqtt === false && node.hopsAway === 0
? "Direct"
: `${node.hopsAway?.toString()} ${node.hopsAway ?? 0 > 1 ? "hops" : "hop"
: `${node.hopsAway?.toString()} ${
node.hopsAway > 1 ? "hops" : "hop"
} away`
: "-"}
{node.viaMqtt === true ? ", via MQTT" : ""}
@ -165,7 +183,8 @@ const NodesPage = (): JSX.Element => {
onOpenChange={() => setSelectedLocation(undefined)}
/>
</div>
</PageLayout>
<Footer />
</div>
</>
);
};

18
src/tests/setupTests.ts

@ -1,20 +1,12 @@
import { afterEach, vi } from 'vitest';
import { expect, afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
import { enableMapSet } from "immer";
import * as matchers from '@testing-library/jest-dom/matchers';
import "@testing-library/jest-dom";
import '@testing-library/user-event';
enableMapSet();
// Enable auto mocks for our UI components
//vi.mock('@components/UI/Dialog.tsx');
//vi.mock('@components/UI/Typography/Link.tsx');
vi.mock('idb-keyval', () => ({
get: vi.fn((key) => Promise.resolve(undefined)),
set: vi.fn((key, value) => Promise.resolve()),
del: vi.fn((key) => Promise.resolve()),
clear: vi.fn(() => Promise.resolve()),
keys: vi.fn(() => Promise.resolve([])),
createStore: vi.fn((dbName, storeName) => ({
})),
}));
globalThis.ResizeObserver = class {
observe() { }
unobserve() { }

4
vite.config.ts

@ -32,10 +32,6 @@ export default defineConfig({
define: {
'import.meta.env.VITE_COMMIT_HASH': JSON.stringify(hash),
},
build: {
emptyOutDir: true,
assetsDir: './',
},
resolve: {
alias: {
'@app': path.resolve(process.cwd(), './src'),

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

Loading…
Cancel
Save