Browse Source

Merge pull request #594 from meshtastic/master

Merge Master into Stable
pull/595/head
Dan Ditomaso 1 year ago
committed by GitHub
parent
commit
1b8b1b93d6
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,6 +3,7 @@ name: CI
on:
push:
branches:
- main
- master
permissions:

8
.github/workflows/release.yml

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

6
deno.lock

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

2
package.json

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

16
public/Logo.svg

@ -0,0 +1,16 @@
<?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>

After

Width:  |  Height:  |  Size: 1.9 KiB

19
src/App.tsx

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

86
src/components/BatteryStatus.tsx

@ -0,0 +1,86 @@
import React from 'react';
import {
PlugZapIcon,
BatteryFullIcon,
BatteryMediumIcon,
BatteryLowIcon,
} from 'lucide-react';
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
interface DeviceMetrics {
batteryLevel?: number | null;
voltage?: number | null;
}
interface BatteryStatusProps {
deviceMetrics?: DeviceMetrics | null;
}
interface BatteryStateConfig {
condition: (level: number) => boolean;
Icon: React.ElementType;
className: string;
text: (level: number) => string;
}
const batteryStates: BatteryStateConfig[] = [
{
condition: level => level > 100,
Icon: PlugZapIcon,
className: 'text-gray-500',
text: () => 'Plugged in',
},
{
condition: level => level > 80,
Icon: BatteryFullIcon,
className: 'text-green-500',
text: level => `${level}% charging`,
},
{
condition: level => level > 20,
Icon: BatteryMediumIcon,
className: 'text-yellow-500',
text: level => `${level}% charging`,
},
{
condition: () => true,
Icon: BatteryLowIcon,
className: 'text-red-500',
text: level => `${level}% charging`,
},
];
const getBatteryState = (level: number) => {
return batteryStates.find(state => state.condition(level));
};
const BatteryStatus: React.FC<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, connection } = useDevice();
const { setDialogOpen, setActivePage, getNode, connection } = useDevice();
const { pinnedItems, togglePinnedItem } = usePinnedItems({ storageName: 'pinnedCommandMenuGroups' });
const groups: Group[] = [
@ -115,12 +115,12 @@ export const CommandPalette = () => {
icon: ArrowLeftRightIcon,
subItems: getDevices().map((device) => ({
label:
device.nodes.get(device.hardware.myNodeNum)?.user?.longName ??
getNode(device.hardware.myNodeNum)?.user?.longName ??
device.hardware.myNodeNum.toString(),
icon: (
<Avatar
text={
device.nodes.get(device.hardware.myNodeNum)?.user?.shortName ??
getNode(device.hardware.myNodeNum)?.user?.shortName ??
device.hardware.myNodeNum.toString()
}
/>
@ -219,10 +219,10 @@ export const CommandPalette = () => {
},
},
{
label: "[WIP] Clear Messages",
label: "Clear All Stored Message",
icon: EraserIcon,
action() {
alert("This feature is not implemented");
setDialogOpen("deleteMessages", true);
},
},
],
@ -262,7 +262,7 @@ export const CommandPalette = () => {
type="button"
onClick={() => togglePinnedItem(group.label)}
className={cn(
"transition-all duration-300 scale-100 cursor-pointer m-0.5 p-2 focus:*:data-label:opacity-100"
"transition-all duration-300 scale-100 cursor-pointer p-2 focus:*:data-label:opacity-100"
)}
aria-description={
pinnedItems.includes(group.label)

76
src/components/DeviceSelector.tsx

@ -1,76 +0,0 @@
import { DeviceSelectorButton } from "@components/DeviceSelectorButton.tsx";
import ThemeSwitcher from "@components/ThemeSwitcher.tsx";
import { Separator } from "@components/UI/Seperator.tsx";
import { Code } from "@components/UI/Typography/Code.tsx";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDeviceStore } from "@core/stores/deviceStore.ts";
import { HomeIcon, PlusIcon, SearchIcon } from "lucide-react";
import { Avatar } from "@components/UI/Avatar.tsx";
export const DeviceSelector = () => {
const { getDevices } = useDeviceStore();
const {
selectedDevice,
setSelectedDevice,
setCommandPaletteOpen,
setConnectDialogOpen,
} = useAppStore();
return (
<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

@ -1,25 +0,0 @@
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

@ -0,0 +1,69 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
// Ensure the path is correct for import
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
import { DeleteMessagesDialog } from "@components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx";
vi.mock('@core/stores/messageStore', () => ({
useMessageStore: vi.fn(() => ({
deleteAllMessages: vi.fn(),
})),
}));
describe('DeleteMessagesDialog', () => {
const mockOnOpenChange = vi.fn();
const mockClearAllMessages = vi.fn();
beforeEach(() => {
mockOnOpenChange.mockClear();
mockClearAllMessages.mockClear();
const mockedUseMessageStore = vi.mocked(useMessageStore);
mockedUseMessageStore.mockImplementation(() => ({
deleteAllMessages: mockClearAllMessages
}));
mockedUseMessageStore.mockClear();
});
it('calls onOpenChange with false when the close button (X) is clicked', () => {
render(<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

@ -0,0 +1,63 @@
import { Button } from "@components/UI/Button.tsx";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { AlertTriangleIcon } from "lucide-react";
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
export interface DeleteMessagesDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const DeleteMessagesDialog = ({
open,
onOpenChange,
}: DeleteMessagesDialogProps) => {
const { deleteAllMessages } = useMessageStore();
const handleCloseDialog = () => {
onOpenChange(false);
};
return (
<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,10 +10,11 @@ import {
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { Input } from "@components/UI/Input.tsx";
import { Label } from "@components/UI/Label.tsx";
import { Protobuf } from "@meshtastic/core";
import { useForm } from "react-hook-form";
import { GenericInput } from "@components/Form/FormInput.tsx";
import { validateMaxByteLength } from "@core/utils/string.ts";
export interface User {
longName: string;
@ -24,32 +25,44 @@ export interface DeviceNameDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
const MAX_LONG_NAME_BYTE_LENGTH = 40;
const MAX_SHORT_NAME_BYTE_LENGTH = 4;
export const DeviceNameDialog = ({
open,
onOpenChange,
}: DeviceNameDialogProps) => {
const { hardware, nodes, connection } = useDevice();
const { hardware, getNode, connection } = useDevice();
const myNode = getNode(hardware.myNodeNum);
const myNode = nodes.get(hardware.myNodeNum);
const defaultValues = {
longName: myNode?.user?.longName ?? "Unknown",
shortName: myNode?.user?.shortName ?? "??",
};
const { register, handleSubmit } = useForm<User>({
values: {
longName: myNode?.user?.longName ?? "Unknown",
shortName: myNode?.user?.shortName ?? "Unknown",
},
const { getValues, setValue, reset, control, handleSubmit } = useForm<User>({
values: defaultValues,
});
const { currentLength: currentLongNameLength } = validateMaxByteLength(getValues('longName'), MAX_LONG_NAME_BYTE_LENGTH);
const { currentLength: currentShortNameLength } = validateMaxByteLength(getValues('shortName'), MAX_SHORT_NAME_BYTE_LENGTH);
const onSubmit = handleSubmit((data) => {
connection?.setOwner(
create(Protobuf.Mesh.UserSchema, {
...myNode?.user,
...(myNode?.user ?? {}),
...data,
}),
);
onOpenChange(false);
});
const handleReset = () => {
reset({ longName: "", shortName: "" });
setValue("longName", "");
setValue("shortName", "");
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
@ -60,22 +73,51 @@ export const DeviceNameDialog = ({
The Device will restart once the config is saved.
</DialogDescription>
</DialogHeader>
<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")}
<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,
},
},
}}
/>
</form>
</div>
<DialogFooter>
<Button onClick={() => onSubmit()}>Save</Button>
</DialogFooter>
</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>
</DialogContent>
</Dialog>
);
};
};

8
src/components/Dialog/DialogManager.tsx

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

1
src/components/Dialog/ImportDialog.tsx

@ -109,7 +109,6 @@ 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 { nodes } = useDevice();
const { getNode } = useDevice();
const from = nodes.get(location?.from ?? 0);
const from = getNode(location?.from ?? 0);
const longName = from?.user?.longName ??
(from ? `!${numberToHexUnpadded(from?.num)}` : "Unknown");
const shortName = from?.user?.shortName ??

14
src/components/Dialog/NewDeviceDialog.tsx

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

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

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

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

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

16
src/components/Dialog/NodeOptionsDialog.tsx

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

16
src/components/Dialog/PkiRegenerateDialog.tsx

@ -10,12 +10,22 @@ import {
} from "@components/UI/Dialog.tsx";
export interface PkiRegenerateDialogProps {
text: {
title: string;
description: string;
button: string;
}
open: boolean;
onOpenChange: () => void;
onSubmit: () => void;
}
export const PkiRegenerateDialog = ({
text = {
title: "Regenerate Key Pair",
description: "Are you sure you want to regenerate key pair?",
button: "Regenerate",
},
open,
onOpenChange,
onSubmit,
@ -25,14 +35,14 @@ export const PkiRegenerateDialog = ({
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>Regenerate Key pair?</DialogTitle>
<DialogTitle>{text?.title}</DialogTitle>
<DialogDescription>
Are you sure you want to regenerate key pair?
{text?.description}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="destructive" onClick={() => onSubmit()}>
Regenerate
{text?.button}
</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,55 +1,97 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { beforeEach, describe, expect, it, Mock, vi } from "vitest";
import { RefreshKeysDialog } from "./RefreshKeysDialog";
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 { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
import { expect, test, vi, beforeEach, afterEach } from 'vitest';
import { Protobuf } from "@meshtastic/core";
vi.mock("./useRefreshKeysDialog.ts", () => ({
useRefreshKeysDialog: vi.fn(),
}));
vi.mock("@core/stores/messageStore");
vi.mock("./useRefreshKeysDialog");
describe("RefreshKeysDialog Component", () => {
let handleCloseDialogMock: Mock;
let handleNodeRemoveMock: Mock;
let onOpenChangeMock: Mock;
const mockUseMessageStore = vi.mocked(useMessageStore);
const mockUseRefreshKeysDialog = vi.mocked(useRefreshKeysDialog);
beforeEach(() => {
handleCloseDialogMock = vi.fn();
handleNodeRemoveMock = vi.fn();
onOpenChangeMock = vi.fn();
const getInitialState = () => useDeviceStore.getInitialState?.() ?? { devices: new Map(), remoteDevices: new Map() };
(useRefreshKeysDialog as Mock).mockReturnValue({
handleCloseDialog: handleCloseDialogMock,
handleNodeRemove: handleNodeRemoveMock,
});
});
beforeEach(() => {
useDeviceStore.setState(getInitialState(), true);
vi.clearAllMocks();
});
it("renders the dialog with correct content", () => {
render(<RefreshKeysDialog open={true} onOpenChange={onOpenChangeMock} />);
expect(screen.getByText("Keys Mismatch")).toBeInTheDocument();
expect(screen.getByText("Request New Keys")).toBeInTheDocument();
expect(screen.getByText("Dismiss")).toBeInTheDocument();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("calls handleNodeRemove when 'Request New Keys' button is clicked", () => {
render(<RefreshKeysDialog open={true} onOpenChange={onOpenChangeMock} />);
fireEvent.click(screen.getByText("Request New Keys"));
expect(handleNodeRemoveMock).toHaveBeenCalled();
});
test("renders dialog when there is a node error for the active chat", () => {
const deviceId = 1;
const nodeWithErrorNum = 12345;
const activeChatNum = nodeWithErrorNum;
it("calls handleCloseDialog when 'Dismiss' button is clicked", () => {
render(<RefreshKeysDialog open={true} onOpenChange={onOpenChangeMock} />);
fireEvent.click(screen.getByText("Dismiss"));
expect(handleCloseDialogMock).toHaveBeenCalled();
});
const deviceStore = useDeviceStore.getState().addDevice(deviceId);
deviceStore.addNodeInfo({
num: nodeWithErrorNum,
user: {
id: nodeWithErrorNum.toString(),
publicKey: new Uint8Array(0),
hwModel: Protobuf.Mesh.HardwareModel.HELTEC_V3,
longName: "Problem Node Long",
shortName: "ProbNode",
isLicensed: false,
macaddr: new Uint8Array(0)
},
lastHeard: Date.now() / 1000,
snr: 10
} as Protobuf.Mesh.NodeInfo);
deviceStore.setNodeError(activeChatNum, "PKI_MISMATCH");
const updatedDeviceState = useDeviceStore.getState().getDevice(deviceId);
if (!updatedDeviceState) {
throw new Error("Failed to get updated device state from store for provider");
}
it("calls onOpenChange when dialog close button is clicked", () => {
render(<RefreshKeysDialog open={true} onOpenChange={onOpenChangeMock} />);
fireEvent.click(screen.getByRole("button", { name: /close/i }));
expect(handleCloseDialogMock).toHaveBeenCalled();
mockUseMessageStore.mockReturnValue({ activeChat: activeChatNum });
const mockHandleClose = vi.fn();
const mockHandleRemove = vi.fn();
mockUseRefreshKeysDialog.mockReturnValue({
handleCloseDialog: mockHandleClose,
handleNodeRemove: mockHandleRemove,
});
it("does not render when open is false", () => {
render(<RefreshKeysDialog open={false} onOpenChange={onOpenChangeMock} />);
expect(screen.queryByText("Keys Mismatch")).not.toBeInTheDocument();
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);
const currentDeviceState = useDeviceStore.getState().getDevice(deviceId);
if (!currentDeviceState) throw new Error("Device not found");
mockUseMessageStore.mockReturnValue({ activeChat: activeChatNum });
mockUseRefreshKeysDialog.mockReturnValue({
handleCloseDialog: vi.fn(),
handleNodeRemove: vi.fn(),
});
const { container } = render(
<DeviceContext.Provider value={currentDeviceState}>
<RefreshKeysDialog open onOpenChange={vi.fn()} />
</DeviceContext.Provider>
);
expect(container.firstChild).toBeNull();
});

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

@ -8,6 +8,8 @@ import {
import { Button } from "@components/UI/Button.tsx";
import { LockKeyholeOpenIcon } from "lucide-react";
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
export interface RefreshKeysDialogProps {
open: boolean;
@ -15,16 +17,30 @@ export interface RefreshKeysDialogProps {
}
export const RefreshKeysDialog = ({ open, onOpenChange }: RefreshKeysDialogProps) => {
const { activeChat } = useMessageStore();
const { nodeErrors, getNode } = useDevice();
const { handleCloseDialog, handleNodeRemove } = useRefreshKeysDialog();
const nodeErrorNum = nodeErrors.get(activeChat);
if (!nodeErrorNum) {
return null;
}
const nodeWithError = getNode(nodeErrorNum.node);
const text = {
title: `Keys Mismatch - ${nodeWithError?.user?.longName ?? ""}`,
description: `Your node is unable to send a direct message to node: ${nodeWithError?.user?.longName ?? ""} (${nodeWithError?.user?.shortName ?? ""}). This is due to the remote node's current public key does not match the previously stored key for this node.`,
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-8 flex flex-col gap-2">
<DialogClose onClick={handleCloseDialog} />
<DialogHeader>
<DialogTitle>Keys Mismatch</DialogTitle>
<DialogTitle>{text.title}</DialogTitle>
</DialogHeader>
Your node is unable to send a direct message to this node. This is due to the remote node's current public key not matching the previously stored key for this node.
{text.description}
<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">
@ -40,14 +56,12 @@ 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/appStore.ts", () => ({
useAppStore: vi.fn(() => ({ activeChat: "chat-123" })),
vi.mock("@core/stores/messageStore", () => ({
useMessageStore: vi.fn(() => ({ activeChat: "chat-123" })),
}));
vi.mock("@core/stores/deviceStore.ts", () => ({
vi.mock("@core/stores/deviceStore", () => ({
useDevice: vi.fn(() => ({
removeNode: vi.fn(),
setDialogOpen: vi.fn(),
@ -23,46 +23,50 @@ describe("useRefreshKeysDialog Hook", () => {
let clearNodeErrorMock: Mock;
beforeEach(() => {
vi.clearAllMocks();
removeNodeMock = vi.fn();
setDialogOpenMock = vi.fn();
getNodeErrorMock = vi.fn();
getNodeErrorMock = vi.fn().mockReturnValue(undefined);
clearNodeErrorMock = vi.fn();
(useDevice as Mock).mockReturnValue({
vi.mocked(useDevice).mockReturnValue({
removeNode: removeNodeMock,
setDialogOpen: setDialogOpenMock,
getNodeError: getNodeErrorMock,
clearNodeError: clearNodeErrorMock,
});
vi.mocked(useMessageStore).mockReturnValue({
activeChat: "chat-123"
});
});
it("handleNodeRemove should remove the node and update dialog if there is an error", () => {
getNodeErrorMock.mockReturnValue({ node: "node-abc" });
const { result } = renderHook(() => useRefreshKeysDialog());
act(() => { result.current.handleNodeRemove(); });
act(() => {
result.current.handleNodeRemove();
});
expect(getNodeErrorMock).toHaveBeenCalledTimes(1);
expect(getNodeErrorMock).toHaveBeenCalledWith("chat-123");
expect(clearNodeErrorMock).toHaveBeenCalledTimes(1);
expect(clearNodeErrorMock).toHaveBeenCalledWith("chat-123");
expect(removeNodeMock).toHaveBeenCalledTimes(1);
expect(removeNodeMock).toHaveBeenCalledWith("node-abc");
expect(setDialogOpenMock).toHaveBeenCalledTimes(1);
expect(setDialogOpenMock).toHaveBeenCalledWith("refreshKeys", false);
});
it("handleNodeRemove should do nothing if there is no error", () => {
getNodeErrorMock.mockReturnValue(undefined);
const { result } = renderHook(() => useRefreshKeysDialog());
act(() => { result.current.handleNodeRemove(); });
act(() => {
result.current.handleNodeRemove();
});
expect(getNodeErrorMock).toHaveBeenCalledTimes(1);
expect(getNodeErrorMock).toHaveBeenCalledWith("chat-123");
expect(clearNodeErrorMock).not.toHaveBeenCalled();
expect(removeNodeMock).not.toHaveBeenCalled();
expect(setDialogOpenMock).not.toHaveBeenCalled();
expect(clearNodeErrorMock).not.toHaveBeenCalled();
});
it("handleCloseDialog should close the dialog", () => {
@ -72,6 +76,7 @@ describe("useRefreshKeysDialog Hook", () => {
result.current.handleCloseDialog();
});
expect(setDialogOpenMock).toHaveBeenCalledTimes(1);
expect(setDialogOpenMock).toHaveBeenCalledWith("refreshKeys", false);
});
});
});

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

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

4
src/components/Dialog/RemoveNodeDialog.tsx

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

1
src/components/Dialog/ShutdownDialog.tsx

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

10
src/components/Dialog/TracerouteResponseDialog.tsx

@ -23,17 +23,17 @@ export const TracerouteResponseDialog = ({
open,
onOpenChange,
}: TracerouteResponseDialogProps) => {
const { nodes } = useDevice();
const { getNode } = useDevice();
const route: number[] = traceroute?.data.route ?? [];
const routeBack: number[] = traceroute?.data.routeBack ?? [];
const snrTowards = traceroute?.data.snrTowards ?? [];
const snrBack = traceroute?.data.snrBack ?? [];
const from = nodes.get(traceroute?.from ?? 0);
const snrTowards = (traceroute?.data.snrTowards ?? []).map(snr => snr / 4);
const snrBack = (traceroute?.data.snrBack ?? []).map(snr => snr / 4);
const from = getNode(traceroute?.from ?? 0);
const longName = from?.user?.longName ??
(from ? `!${numberToHexUnpadded(from?.num)}` : "Unknown");
const shortName = from?.user?.shortName ??
(from ? `${numberToHexUnpadded(from?.num).substring(0, 4)}` : "UNK");
const to = nodes.get(traceroute?.to ?? 0);
const to = getNode(traceroute?.to ?? 0);
return (
<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";
import { eventBus } from "@core/utils/eventBus";
import { useUnsafeRolesDialog, UNSAFE_ROLES } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
import { eventBus } from "@core/utils/eventBus.ts";
vi.mock('@core/utils/eventBus', () => ({
eventBus: {

31
src/components/Form/FormInput.tsx

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

22
src/components/Form/FormPasswordGenerator.tsx

@ -4,10 +4,9 @@ import type {
} from "@components/Form/DynamicForm.tsx";
import type { ButtonVariant } from "../UI/Button.tsx";
import { Generator } from "@components/UI/Generator.tsx";
import { Eye, EyeOff } from "lucide-react";
import type { ChangeEventHandler } from "react";
import { useState } from "react";
import { Controller, type FieldValues } from "react-hook-form";
import { usePasswordVisibilityToggle } from "@core/hooks/usePasswordVisibilityToggle.ts";
export interface PasswordGeneratorProps<T> extends BaseFormBuilderProps<T> {
type: "passwordGenerator";
@ -15,7 +14,7 @@ export interface PasswordGeneratorProps<T> extends BaseFormBuilderProps<T> {
hide?: boolean;
bits?: { text: string; value: string; key: string }[];
devicePSKBitCount: number;
inputChange: ChangeEventHandler;
inputChange: ChangeEventHandler<HTMLInputElement> | undefined;
selectChange: (event: string) => void;
actionButtons: {
text: string;
@ -23,6 +22,8 @@ export interface PasswordGeneratorProps<T> extends BaseFormBuilderProps<T> {
variant: ButtonVariant;
className?: string;
}[];
showPasswordToggle?: boolean;
showCopyButton?: boolean;
}
export function PasswordGenerator<T extends FieldValues>({
@ -30,10 +31,7 @@ export function PasswordGenerator<T extends FieldValues>({
field,
disabled,
}: GenericFormElementProps<T, PasswordGeneratorProps<T>>) {
const [passwordShown, setPasswordShown] = useState(false);
const togglePasswordVisiblity = () => {
setPasswordShown(!passwordShown);
};
const { isVisible } = usePasswordVisibilityToggle()
return (
<Controller
@ -41,14 +39,8 @@ export function PasswordGenerator<T extends FieldValues>({
control={control}
render={({ field: { value, ...rest } }) => (
<Generator
type={field.hide && !passwordShown ? "password" : "text"}
type={field.hide && !isVisible ? "password" : "text"}
id={field.id}
action={field.hide
? {
icon: passwordShown ? EyeOff : Eye,
onClick: togglePasswordVisiblity,
}
: undefined}
devicePSKBitCount={field.devicePSKBitCount}
bits={field.bits}
inputChange={field.inputChange}
@ -56,6 +48,8 @@ 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,17 +20,16 @@ export const FieldWrapper = ({
}: FieldWrapperProps) => (
<div className="pt-6 sm:pt-5">
<fieldset aria-labelledby="label-notifications">
<div className="sm:grid sm:grid-cols-3 sm:items-baseline sm:gap-4">
{/* 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">
<Label htmlFor={fieldName}>{label}</Label>
<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 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>
</div>
</div>

12
src/components/PageComponents/Channel.tsx

@ -1,4 +1,4 @@
import type { ChannelValidation } from "@app/validation/channel.tsx";
import type { ChannelValidation } from "@app/validation/channel.ts";
import { create } from "@bufbuild/protobuf";
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import { useToast } from "@core/hooks/useToast.ts";
@ -97,7 +97,8 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
settings: {
...channel?.settings,
psk: pass,
moduleSettings: {...channel?.settings?.moduleSettings,
moduleSettings: {
...channel?.settings?.moduleSettings,
positionPrecision: channel?.settings?.moduleSettings?.positionPrecision === undefined ? 10 : channel?.settings?.moduleSettings?.positionPrecision,
}
},
@ -142,6 +143,8 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
hide: true,
properties: {
value: pass,
showPasswordToggle: true,
showCopyButton: true,
},
},
{
@ -206,6 +209,11 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
]}
/>
<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.tsx";
import type { BluetoothValidation } from "@app/validation/config/bluetooth.ts";
import { create } from "@bufbuild/protobuf";
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";

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', () => ({
vi.mock('@core/stores/deviceStore.ts', () => ({
useDevice: vi.fn()
}));
vi.mock('@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog', () => ({
vi.mock('@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts', () => ({
useUnsafeRolesDialog: vi.fn()
}));

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

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

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,11 +195,8 @@ export const Security = () => {
],
properties: {
value: state.privateKey,
action: {
icon: state.privateKeyVisible ? EyeOff : Eye,
onClick: () =>
dispatch({ type: "TOGGLE_PRIVATE_KEY_VISIBILITY" }),
},
showCopyButton: true,
showPasswordToggle: true,
},
},
{
@ -211,6 +208,7 @@ export const Security = () => {
"Sent out to other nodes on the mesh to allow them to compute a shared secret key",
properties: {
value: state.publicKey,
showCopyButton: true,
},
},
],
@ -271,11 +269,7 @@ export const Security = () => {
],
properties: {
value: state.adminKey,
action: {
icon: state.adminKeyVisible ? EyeOff : Eye,
onClick: () =>
dispatch({ type: "TOGGLE_ADMIN_KEY_VISIBILITY" }),
},
showCopyButton: true,
},
},
],
@ -302,6 +296,11 @@ export const Security = () => {
]}
/>
<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,10 +7,12 @@ import { subscribeAll } from "@core/subscriptions.ts";
import { randId } from "@core/utils/randId.ts";
import { BleConnection, ServiceUuid } from "@meshtastic/js";
import { useCallback, useEffect, useState } from "react";
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
export const BLE = ({ closeDialog }: TabElementProps) => {
export const BLE = ({ setConnectionInProgress, closeDialog }: TabElementProps) => {
const [bleDevices, setBleDevices] = useState<BluetoothDevice[]>([]);
const { addDevice } = useDeviceStore();
const messageStore = useMessageStore()
const { setSelectedDevice } = useAppStore();
const updateBleDeviceList = useCallback(async (): Promise<void> => {
@ -30,7 +32,7 @@ export const BLE = ({ closeDialog }: TabElementProps) => {
device: bleDevice,
});
device.addConnection(connection);
subscribeAll(device, connection);
subscribeAll(device, connection, messageStore);
closeDialog();
};
@ -41,8 +43,9 @@ export const BLE = ({ closeDialog }: TabElementProps) => {
{bleDevices.map((device) => (
<Button
key={device.id}
className="dark:bg-slate-900 dark:text-white"
variant="default"
onClick={() => {
setConnectionInProgress(true);
onConnect(device);
}}
>
@ -54,8 +57,10 @@ export const BLE = ({ closeDialog }: TabElementProps) => {
)}
</div>
<Button
className="dark:bg-slate-900 dark:text-white"
variant="default"
onClick={async () => {
await navigator.bluetooth
.requestDevice({
filters: [{ services: [ServiceUuid] }],
@ -65,6 +70,11 @@ export const BLE = ({ closeDialog }: TabElementProps) => {
if (exists === -1) {
setBleDevices(bleDevices.concat(device));
}
}).catch((error) => {
console.error("Error requesting device:", error);
setConnectionInProgress(false);
}).finally(() => {
setConnectionInProgress(false);
});
}}
>
@ -72,4 +82,4 @@ export const BLE = ({ closeDialog }: TabElementProps) => {
</Button>
</div>
);
};
};

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

@ -1,6 +1,7 @@
import type { TabElementProps } from "@components/Dialog/NewDeviceDialog.tsx";
import { Button } from "@components/UI/Button.tsx";
import { Input } from "@components/UI/Input.tsx";
import { Link } from "@components/UI/Typography/Link.tsx";
import { Label } from "@components/UI/Label.tsx";
import { Switch } from "@components/UI/Switch.tsx";
import { useAppStore } from "@core/stores/appStore.ts";
@ -11,25 +12,28 @@ import { MeshDevice } from "@meshtastic/core";
import { TransportHTTP } from "@meshtastic/transport-http";
import { useState } from "react";
import { useForm, useController } from "react-hook-form";
import { AlertTriangle } from "lucide-react";
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
interface FormData {
ip: string;
tls: boolean;
}
export const HTTP = ({ closeDialog }: TabElementProps) => {
export const HTTP = ({ closeDialog, setConnectionInProgress, connectionInProgress }: TabElementProps) => {
const isURLHTTPS = location.protocol === "https:";
const { addDevice } = useDeviceStore();
const messageStore = useMessageStore();
const { setSelectedDevice } = useAppStore();
const { control, handleSubmit, register } = useForm<FormData>({
defaultValues: {
ip: ["client.meshtastic.org", "localhost"].includes(
window.location.hostname,
globalThis.location.hostname,
)
? "meshtastic.local"
: window.location.host,
: globalThis.location.host,
tls: isURLHTTPS ? true : false,
},
});
@ -38,49 +42,85 @@ export const HTTP = ({ closeDialog }: TabElementProps) => {
field: { value: tlsValue, onChange: setTLS },
} = useController({ name: "tls", control });
const [connectionInProgress, setConnectionInProgress] = useState(false);
const [connectionError, setConnectionError] = useState<{ host: string; secure: boolean } | null>(null);
const onSubmit = handleSubmit(async (data) => {
setConnectionInProgress(true);
const id = randId();
const device = addDevice(id);
const transport = await TransportHTTP.create(data.ip, data.tls);
const connection = new MeshDevice(transport, id);
connection.configure();
setSelectedDevice(id);
device.addConnection(connection);
subscribeAll(device, connection);
closeDialog();
setConnectionError(null);
try {
const id = randId();
const transport = await TransportHTTP.create(data.ip, data.tls);
const device = addDevice(id);
const connection = new MeshDevice(transport, id);
connection.configure();
setSelectedDevice(id);
device.addConnection(connection);
subscribeAll(device, connection, messageStore);
closeDialog();
} catch (error) {
console.error("Connection error:", error);
// Capture all connection errors regardless of type
setConnectionError({ host: data.ip, secure: data.tls });
setConnectionInProgress(false);
}
});
return (
<form className="flex w-full flex-col gap-2 p-4" onSubmit={onSubmit}>
<div className="flex h-48 flex-col gap-2">
<div className="flex flex-col gap-2" style={{ minHeight: "12rem" }}>
<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-900"
disabled={connectionInProgress}
className="text-slate-900 dark:text-slate-100"
{...register("ip")}
/>
</div>
<div className="flex items-center gap-2 mt-2">
<Switch
onCheckedChange={setTLS}
disabled={isURLHTTPS || connectionInProgress}
disabled={isURLHTTPS}
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>
<Button
type="submit"
disabled={connectionInProgress}
className="dark:bg-slate-900 dark:text-white"
variant={"default"}
>
<span>{connectionInProgress ? "Connecting..." : "Connect"}</span>
</Button>

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

@ -8,10 +8,12 @@ import { randId } from "@core/utils/randId.ts";
import { MeshDevice } from "@meshtastic/core";
import { TransportWebSerial } from "@meshtastic/transport-web-serial";
import { useCallback, useEffect, useState } from "react";
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
export const Serial = ({ closeDialog }: TabElementProps) => {
export const Serial = ({ setConnectionInProgress, closeDialog }: TabElementProps) => {
const [serialPorts, setSerialPorts] = useState<SerialPort[]>([]);
const { addDevice } = useDeviceStore();
const messageStore = useMessageStore()
const { setSelectedDevice } = useAppStore();
const updateSerialPortList = useCallback(async () => {
@ -36,7 +38,7 @@ export const Serial = ({ closeDialog }: TabElementProps) => {
const connection = new MeshDevice(transport, id);
connection.configure();
device.addConnection(connection);
subscribeAll(device, connection);
subscribeAll(device, connection, messageStore);
closeDialog();
};
@ -50,8 +52,9 @@ export const Serial = ({ closeDialog }: TabElementProps) => {
<Button
key={`${usbVendorId ?? "UNK"}-${usbProductId ?? "UNK"}-${index}`}
disabled={port.readable !== null}
className="dark:bg-slate-900 dark:text-white"
variant="default"
onClick={async () => {
setConnectionInProgress(true);
await onConnect(port);
}}
>
@ -65,10 +68,15 @@ export const Serial = ({ closeDialog }: TabElementProps) => {
)}
</div>
<Button
className="dark:bg-slate-900 dark:text-white"
variant="default"
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,12 +7,7 @@ import { Mono } from "@components/generic/Mono.tsx";
import { TimeAgo } from "@components/generic/TimeAgo.tsx";
import { Protobuf } from "@meshtastic/core";
import type { Protobuf as ProtobufType } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import {
BatteryChargingIcon,
BatteryFullIcon,
BatteryLowIcon,
BatteryMediumIcon,
Dot,
LockIcon,
LockOpenIcon,
@ -27,34 +22,35 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@radix-ui/react-tooltip";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDevice } from "@core/stores/deviceStore.ts";
import { MessageType, useMessageStore } from "../../../core/stores/messageStore/index.ts";
import BatteryStatus from "@components/BatteryStatus.tsx";
export interface NodeDetailProps {
node: ProtobufType.Mesh.NodeInfo;
}
export const NodeDetail = ({ node }: NodeDetailProps) => {
const { setChatType, setActiveChat } = useAppStore();
const { setChatType, setActiveChat } = useMessageStore();
const { setActivePage } = useDevice();
const name = node.user?.longName || `!${numberToHexUnpadded(node.num)}`;
const name = node.user?.longName ?? `UNK`;
const shortName = node.user?.shortName ?? "UNK";
const hwModel = node.user?.hwModel ?? 0;
const hardwareType =
Protobuf.Mesh.HardwareModel[hwModel]?.replaceAll("_", " ") ?? `${hwModel}`;
function handleDirectMessage() {
setChatType("direct");
setChatType(MessageType.Direct);
setActiveChat(node.num);
setActivePage("messages");
}
return (
<div className="dark:text-slate-900 p-1">
<div className="p-1 text-slate-900">
<div className="flex gap-2">
<div className="flex flex-col items-center gap-2 min-w-6 pt-1">
<Avatar text={shortName} />
<Avatar text={shortName} size="sm" />
<div onFocusCapture={(e) => {
// Required to prevent DM tooltip auto-appearing on creation
e.stopPropagation();
@ -80,11 +76,10 @@ 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>
@ -114,24 +109,7 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
{hardwareType !== "UNSET" && <Subtle>{hardwareType}</Subtle>}
{!!node.deviceMetrics?.batteryLevel && (
<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>
<BatteryStatus deviceMetrics={node.deviceMetrics} />
)}
<div className="flex gap-2 items-center">
@ -199,7 +177,7 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
{!!node.deviceMetrics?.airUtilTx && (
<div className="grow">
<div>Airtime Util</div>
<Mono>{node.deviceMetrics?.airUtilTx.toPrecision(3)}%</Mono>
<Mono className="text-gray-500">{node.deviceMetrics?.airUtilTx.toPrecision(3)}%</Mono>
</div>
)}
</div>
@ -207,7 +185,7 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
{node.snr !== 0 && (
<div className="mt-2">
<div>SNR</div>
<Mono className="flex items-center text-xs">
<Mono className="flex items-center text-xs text-gray-500">
{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 { type MessageWithState, useDevice } from "@core/stores/deviceStore.ts";
import { Message } from "@components/PageComponents/Messages/Message.tsx";
import { MessageItem } from "@components/PageComponents/Messages/MessageItem.tsx";
import { InboxIcon } from "lucide-react";
import { useCallback, useEffect, useRef } from "react";
import { Message } from "@core/stores/messageStore/types.ts";
export interface ChannelChatProps {
messages?: MessageWithState[];
messages?: Message[];
}
const EmptyState = () => (
<div className="flex flex-col place-content-center place-items-center p-8 text-white">
<InboxIcon className="h-8 w-8 mb-2" />
<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" />
<span className="text-sm">No Messages</span>
</div>
);
export const ChannelChat = ({
messages = [],
}: ChannelChatProps) => {
const { nodes } = useDevice();
export const ChannelChat = ({ messages = [] }: ChannelChatProps) => {
const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLUListElement>(null);
const userScrolledUpRef = useRef(false);
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => {
requestAnimationFrame(() => {
messagesEndRef.current?.scrollIntoView({ behavior });
});
}, []);
const scrollToBottom = useCallback(() => {
useEffect(() => {
const scrollContainer = scrollContainerRef.current;
if (scrollContainer) {
const isNearBottom =
scrollContainer.scrollHeight -
scrollContainer.scrollTop -
scrollContainer.clientHeight <
100;
if (!scrollContainer) return;
const isScrolledToBottom = scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight <= 10;
if (isNearBottom) {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}
if (isScrolledToBottom || !userScrolledUpRef.current) {
scrollToBottom('smooth');
}
}, []);
}, [messages, scrollToBottom]);
useEffect(() => {
scrollToBottom();
}, [scrollToBottom, messages]);
const scrollContainer = scrollContainerRef.current;
const handleScroll = () => {
if (!scrollContainer) return;
const isAtBottom = scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight <= 10;
userScrolledUpRef.current = !isAtBottom;
};
scrollContainer?.addEventListener('scroll', handleScroll, { passive: true });
return () => {
scrollContainer?.removeEventListener('scroll', handleScroll);
};
}, []);
if (!messages?.length) {
return (
<div className="flex flex-col h-full container mx-auto">
<div className="flex-1 flex items-center justify-center">
<EmptyState />
</div>
<div className="flex flex-1 flex-col items-center justify-center">
<EmptyState />
<div ref={messagesEndRef} />
</div>
);
}
return (
<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>
<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>
<div ref={messagesEndRef} className="h-px" />
</ul>
);
};

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

@ -1,175 +0,0 @@
import { memo, useMemo } from "react";
import {
Tooltip,
TooltipArrow,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@components/UI/Tooltip.tsx";
import {
type MessageWithState,
useDeviceStore,
} from "@core/stores/deviceStore.ts";
import { cn } from "@core/utils/cn.ts";
import { Avatar } from "@components/UI/Avatar.tsx";
import type { Protobuf } from "@meshtastic/core";
import { AlertCircle, CheckCircle2, CircleEllipsis, LucideIcon } from "lucide-react";
type MessageStateValue = {
state: string;
icon: LucideIcon;
displayText: string;
}
type MessageState = MessageWithState["state"];
interface MessageProps {
lastMsgSameUser: boolean;
message: MessageWithState;
sender: Protobuf.Mesh.NodeInfo;
}
interface StatusTooltipProps {
state: MessageState;
children: React.ReactNode;
}
interface StatusIconProps {
state: MessageState;
className?: string;
}
const MESSAGE_STATES: Record<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

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

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

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

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

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

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

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

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

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

135
src/components/PageLayout.tsx

@ -1,77 +1,126 @@
import React from 'react';
import { cn } from "@core/utils/cn.ts";
import { AlignLeftIcon, type LucideIcon } from "lucide-react";
import { type LucideIcon } from "lucide-react";
import Footer from "@components/UI/Footer.tsx";
import { Spinner } from "@components/UI/Spinner.tsx";
import { ErrorBoundary } from "react-error-boundary";
import { ErrorPage } from "@components/UI/ErrorPage.tsx";
export interface ActionItem {
key: string;
icon: LucideIcon;
iconClasses?: string;
onClick: () => void;
disabled?: boolean;
isLoading?: boolean;
ariaLabel?: string;
}
export interface PageLayoutProps {
label: string;
noPadding?: boolean;
actions?: ActionItem[];
children: React.ReactNode;
className?: string;
actions?: {
icon: LucideIcon;
iconClasses?: string;
onClick: () => void;
disabled?: boolean;
isLoading?: boolean;
}[];
leftBar?: React.ReactNode;
rightBar?: React.ReactNode;
noPadding?: boolean;
leftBarClassName?: string;
rightBarClassName?: string;
topBarClassName?: string;
contentClassName?: string;
}
export const PageLayout = ({
label,
noPadding,
actions,
className,
children,
leftBar,
rightBar,
noPadding,
leftBarClassName,
rightBarClassName,
topBarClassName,
contentClassName
}: PageLayoutProps) => {
return (
<ErrorBoundary FallbackComponent={ErrorPage}>
<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"
<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
)}
>
<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">
{/* 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">
{actions?.map((action) => (
<button
key={action.icon.displayName}
key={action.key}
type="button"
disabled={action?.disabled}
className="transition-all hover:text-accent"
disabled={action.disabled || action.isLoading}
className="text-foreground transition-colors hover:text-accent disabled:opacity-50 disabled:cursor-not-allowed"
onClick={action.onClick}
aria-label={action.ariaLabel || `Action ${action.key}`}
aria-disabled={action.disabled}
aria-busy={action.isLoading}
>
{action?.isLoading ? <Spinner /> : (
<action.icon
className={action.iconClasses}
aria-disabled={action.disabled}
/>
)}
<div className="mr-6">
{action.isLoading ? (
<Spinner size="md" />
) : (
<action.icon
className={cn("h-5 w-5", action.iconClasses)}
/>
)}
</div>
</button>
))}
</div>
</div>
</div>
</div>
<div
className={cn(
"flex h-full w-full flex-col overflow-y-auto",
!noPadding && "pl-3 pr-3 ",
className
)}
>
{children}
</header>
<main
className={cn(
"flex-1 flex flex-col",
"overflow-hidden",
!noPadding && "px-2",
contentClassName
)}
>
{children}
</main>
<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,148 +1,241 @@
import React from "react";
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
import { SidebarButton } from "@components/UI/Sidebar/sidebarButton.tsx";
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import type { Page } from "@core/stores/deviceStore.ts";
import { Spinner } from "@components/UI/Spinner.tsx";
import { Avatar } from "@components/UI/Avatar.tsx";
import {
BatteryMediumIcon,
CircleChevronLeft,
CpuIcon,
EditIcon,
LayersIcon,
type LucideIcon,
MapIcon,
MessageSquareIcon,
PenLine,
SearchIcon,
SettingsIcon,
SidebarCloseIcon,
SidebarOpenIcon,
UsersIcon,
ZapIcon,
} from "lucide-react";
import { useState } from "react";
import { cn } from "@core/utils/cn.ts";
import { useSidebar } from "@core/stores/sidebarStore.tsx";
import ThemeSwitcher from "@components/ThemeSwitcher.tsx";
import { useAppStore } from "@core/stores/appStore.ts";
import BatteryStatus from "@components/BatteryStatus.tsx";
import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.tsx";
export interface SidebarProps {
children?: React.ReactNode;
}
interface NavLink {
name: string;
icon: LucideIcon;
page: Page;
}
const CollapseToggleButton = () => {
const { isCollapsed, toggleSidebar } = useSidebar();
const buttonLabel = isCollapsed ? "Open sidebar" : "Close sidebar";
return (
<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, nodes, metadata } = useDevice();
const myNode = nodes.get(hardware.myNodeNum);
const { hardware, getNode, getNodesLength, metadata, activePage, setActivePage, setDialogOpen } = useDevice();
const { setCommandPaletteOpen } = useAppStore();
const myNode = getNode(hardware.myNodeNum);
const { isCollapsed } = useSidebar();
const myMetadata = metadata.get(0);
const { activePage, setActivePage, setDialogOpen } = useDevice();
const [showSidebar, setShowSidebar] = useState<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: "Messages",
icon: MessageSquareIcon,
page: "messages",
},
{
name: "Map",
icon: MapIcon,
page: "map",
},
{
name: "Config",
icon: SettingsIcon,
page: "config",
},
{
name: "Channels",
icon: LayersIcon,
page: "channels",
},
{
name: `Nodes (${nodes.size - 1})`,
name: `Nodes (${Math.max(getNodesLength() - 1, 0)})`,
icon: UsersIcon,
page: "nodes",
},
];
return showSidebar
? (
<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">
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">
{myNode === undefined ? (
<div className="flex flex-col items-center justify-center px-8 py-6">
<div className="flex flex-col items-center justify-center py-6">
<Spinner />
<Subtle className="mt-2">Loading device info...</Subtle>
<Subtle
className={cn(
'mt-4 transition-opacity duration-300',
isCollapsed ? 'opacity-0 invisible' : 'opacity-100 visible'
)}
>
Loading...
</Subtle>
</div>
) : (
<>
<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
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>
<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"
className="transition-all hover:text-accent"
aria-label="Edit device name"
className="p-1 rounded transition-colors hover:text-accent"
onClick={() => setDialogOpen("deviceName", true)}
>
<EditIcon size={16} />
<PenLine size={22} />
</button>
<button type="button" onClick={() => setShowSidebar(false)}>
<SidebarCloseIcon size={24} />
<ThemeSwitcher />
<button
type="button"
className="transition-all hover:text-accent"
onClick={() => setCommandPaletteOpen(true)}
>
<SearchIcon />
</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-5" />,
dark: <Moon className="size-5" />,
system: <Monitor className="size-5" />,
light: <Sun className="size-6" />,
dark: <Moon className="size-6" />,
system: <Monitor className="size-6" />,
};
const toggleTheme = () => {
@ -30,15 +30,15 @@ export default function ThemeSwitcher({
<button
type="button"
className={cn(
"transition-all duration-300 scale-100 cursor-pointer m-6 p-2 focus:*:data-label:opacity-100",
"transition-all duration-300 scale-100 cursor-pointer m-3 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-5 opacity-0 rounded-lg"
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"
>
{firstCharOfPreference.toLocaleUpperCase() +
(restOfPreference ?? []).join("")}

71
src/components/UI/Avatar.tsx

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

46
src/components/UI/Button.tsx

@ -1,21 +1,20 @@
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "@core/utils/cn.ts";
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus:outline-hidden focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:opacity-50 dark:focus:ring-slate-400 disabled:pointer-events-none dark:focus:ring-offset-slate-900 cursor-pointer",
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:opacity-50 dark:focus:ring-slate-400 disabled:cursor-not-allowed dark:focus:ring-offset-slate-900 cursor-pointer",
{
variants: {
variant: {
default:
"bg-slate-900 text-white dark:bg-slate-900 hover:dark:bg-slate-700 dark:text-slate-100 hover:bg-slate-800 ",
"bg-slate-900 text-white dark:bg-slate-50 hover:dark:bg-slate-200 dark:text-slate-900 hover:bg-slate-500",
destructive:
"bg-red-500 text-white hover:bg-red-600 dark:hover:bg-red-600",
success:
"bg-green-500 text-white hover:bg-green-600 dark:hover:bg-green-600",
outline:
"bg-transparent border border-slate-400 hover:bg-slate-100 dark:border-slate-400 dark:text-slate-500",
"bg-transparent border border-slate-400 hover:text-slate-400 dark:hover:text-slate-300 dark:border-slate-400 dark:text-slate-100 ",
subtle:
"bg-slate-100 text-slate-700 hover:bg-slate-200 dark:bg-slate-500 dark:text-white dark:hover:bg-slate-400",
ghost:
@ -27,6 +26,7 @@ const buttonVariants = cva(
default: "h-10 py-2 px-4",
sm: "h-9 px-2 rounded-md",
lg: "h-11 px-8 rounded-md",
icon: "h-10 w-10",
},
},
defaultVariants: {
@ -39,26 +39,50 @@ const buttonVariants = cva(
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
export interface ButtonProps
extends
React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> { }
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
icon?: React.ReactNode;
iconAlignment?: "left" | "right";
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, disabled, ...props }, ref) => {
(
{
className,
variant,
size,
disabled,
icon,
iconAlignment = "left",
children,
...props
},
ref,
) => {
return (
<button
type="button"
className={cn(
buttonVariants({ variant, size, className }),
{ "cursor-not-allowed": disabled }
{ "cursor-not-allowed": disabled },
"inline-flex items-center"
)}
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, useId } from "react";
import { useState, useEffect, useId } from "react";
import { Check } from "lucide-react";
import { Label } from "@components/UI/Label.tsx";
import { cn } from "@core/utils/cn.ts";
@ -32,6 +32,11 @@ export function Checkbox({
const [isChecked, setIsChecked] = useState(checked || false);
// Make sure setIsChecked state updates with checked
useEffect(() => {
setIsChecked(checked || false);
}, [checked]);
const handleToggle = () => {
if (disabled) return;
@ -66,7 +71,7 @@ export function Checkbox({
>
{isChecked && (
<div className="animate-fade-in scale-100 opacity-100">
<Check className="w-4 h-4 text-slate-900 dark:text-slate-900" />
<Check className="w-4 h-4 text-slate-900 dark:text-slate-200" />
</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" />
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50 text-white" />
<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-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",
"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",
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-700", className)}
className={cn("-mx-1 h-px bg-slate-100 dark:bg-slate-200", 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 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",
"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",
className,
)}
{...props}
@ -60,7 +60,8 @@ const DialogClose = ({
...props
}: DialogPrimitive.DialogCloseProps & React.RefAttributes<HTMLButtonElement> & { className?: string }) => (
<DialogPrimitive.Close
name="close"
aria-label="Close"
data-testid="dialog-close-button"
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,
@ -106,7 +107,7 @@ const DialogTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-slate-900", className)}
className={cn("text-lg font-semibold text-slate-900 dark:text-slate-100", className)}
{...props}
/>
));
@ -118,7 +119,7 @@ const DialogDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-slate-700", className)}
className={cn("text-sm text-slate-800 dark:text-slate-200", 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 p-2", className)}
className={cn("flex mt-auto justify-center py-2 px-4 text-sm lg:text-md", className)}
{...props}
>
<p>

37
src/components/UI/Generator.tsx

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

192
src/components/UI/Input.tsx

@ -1,73 +1,185 @@
import * as React from "react";
import { cn } from "@core/utils/cn.ts";
import { cva, type VariantProps } from "class-variance-authority";
import type { LucideIcon } from "lucide-react";
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";
const inputVariants = cva(
"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",
"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",
{
variants: {
variant: {
default: "border-slate-300 dark:border-slate-700",
invalid: "border-red-500 dark:border-red-500",
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",
},
},
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
React.InputHTMLAttributes<HTMLInputElement>,
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "prefix" | "suffix">,
VariantProps<typeof inputVariants> {
prefix?: string;
suffix?: string;
action?: {
icon: LucideIcon;
onClick: () => void;
};
prefix?: React.ReactNode;
suffix?: React.ReactNode;
showPasswordToggle?: boolean;
showCopyButton?: boolean;
showClearButton?: boolean;
containerClassName?: string;
}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, value, variant, prefix, suffix, action, ...props }, ref) => {
(
{
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
);
return (
<div className="relative w-full">
<div className={cn("relative flex w-full items-stretch", containerClassName)}>
{prefix && (
<label className="inline-flex items-center rounded-l-md bg-slate-100/80 px-3 font-mono text-sm text-slate-600">
<span className="inline-flex items-center rounded-l-md border border-r-0 border-slate-300 bg-slate-100/80 px-3 text-sm text-slate-600 dark:border-slate-700 dark:bg-slate-200 dark:text-slate-700">
{prefix}
</label>
</span>
)}
<input
className={cn(
action && "pr-8",
inputVariants({ variant }),
className,
)}
value={value}
type={inputType === "password" && isVisible ? "text" : inputType}
className={inputClassName}
ref={ref}
value={value}
onChange={onChange}
{...props}
/>
{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 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>
</div>
);
},
}
);
Input.displayName = "Input";
export { Input, inputVariants };
export { Input, inputVariants };

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

@ -0,0 +1,81 @@
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,19 +1,42 @@
import { Heading } from "../Typography/Heading.tsx";
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";
export interface SidebarSectionProps {
interface SidebarSectionProps {
label: string;
subheader?: string;
children: React.ReactNode;
className?: string;
}
export const SidebarSection = ({
label: title,
label,
children,
}: 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>
);
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>
);
};

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

@ -1,32 +1,81 @@
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;
element?;
children?: React.ReactNode;
onClick?: () => void;
disabled?: boolean;
preventCollapse?: boolean;
}
export const SidebarButton = ({
label,
active,
Icon,
element,
count,
children,
onClick,
disabled = false,
}: 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>
);
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>
);
};

87
src/components/UI/Slider.tsx

@ -0,0 +1,87 @@
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-200",
"inline-flex flex-wrap items-center rounded-md p-1 mt-2 bg-slate-200 dark:bg-slate-100",
className,
)}
{...props}

4
src/components/UI/Toast.tsx

@ -28,7 +28,7 @@ const toastVariants = cva(
variants: {
variant: {
default:
"border bg-backgound-primary text-foreground dark:bg-white dark:border-slate-600 dark:text-slate-900",
"border bg-white text-slate-900 dark:text-slate-100 dark:bg-slate-800",
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 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",
"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",
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-400",
"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",
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-2xl font-semibold tracking-tight",
h3: "scroll-m-20 text-lg 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-900",
"font-medium text-slate-900 underline underline-offset-4 dark:text-slate-200",
className,
)}
>

10
src/components/generic/Mono.tsx

@ -1,11 +1,17 @@
import { cn } from "@core/utils/cn.ts";
interface MonoProps extends React.HTMLAttributes<HTMLSpanElement> {
children: React.ReactNode;
className?: string;
}
export const Mono = ({
children,
className,
...rest
}: JSX.IntrinsicElements["span"]) => {
}: MonoProps) => {
return (
<span
className={`font-mono text-sm text-text-secondary ${className ?? ""}`}
className={cn("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,7 +46,6 @@ 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;
@ -60,11 +59,10 @@ 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;
}
@ -74,7 +72,6 @@ export const Table = ({ headings, rows }: TableProps) => {
return 0;
}
// Default comparison for other columns
if (aValue < bValue) {
return sortOrder === "asc" ? -1 : 1;
}
@ -86,17 +83,16 @@ export const Table = ({ headings, rows }: TableProps) => {
return (
<table className="min-w-full">
<thead className="bg-backgound-primary text-sm font-semibold text-text-primary">
<thead className="text-xs font-semibold">
<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)}
>
@ -111,28 +107,29 @@ export const Table = ({ headings, rows }: TableProps) => {
))}
</tr>
</thead>
<tbody>
{sortedRows.map((row, index) => (
<tbody className="max-w-fit">
{sortedRows.map((row, index) => {
// biome-ignore lint/suspicious/noArrayIndexKey: TODO: Once this table is sortable, this should get fixed.
<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>
))}
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>
))}
);
})}
</tbody>
</table>
);

40
src/core/dto/NodeNumToNodeInfoDTO.ts

@ -0,0 +1,40 @@
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

@ -0,0 +1,51 @@
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

@ -0,0 +1,51 @@
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

@ -0,0 +1,294 @@
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

@ -0,0 +1,66 @@
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

@ -0,0 +1,20 @@
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,4 +1,3 @@
import { Types } from "@meshtastic/core";
import { produce } from "immer";
import { create } from "zustand";
@ -25,14 +24,11 @@ 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;
@ -45,8 +41,6 @@ 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;
@ -67,8 +61,6 @@ export const useAppStore = create<AppState>()((set, get) => ({
connectDialogOpen: false,
nodeNumToBeRemoved: 0,
nodeNumDetails: 0,
activeChat: Types.ChannelNumber.Primary,
chatType: "broadcast",
errors: [],
setRasterSources: (sources: RasterSource[]) => {
@ -127,14 +119,6 @@ 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,34 +6,13 @@ 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 =
| "import"
| "QR"
| "shutdown"
| "reboot"
| "rebootOTA"
| "deviceName"
| "nodeRemoval"
| "pkiBackup"
| "nodeDetails"
| "unsafeRoles"
| "refreshKeys";
type QueueStatus = {
res: number, free: number, maxlen: number
}
export type DialogVariant = keyof Device["dialog"];
type NodeError = {
node: number;
@ -49,12 +28,7 @@ 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>[]
@ -64,11 +38,10 @@ export interface Device {
activePage: Page;
activeNode: number;
waypoints: Protobuf.Mesh.Waypoint[];
// currentMetrics: Protobuf.DeviceMetrics;
pendingSettingsChanges: boolean;
messageDraft: string;
queueStatus: QueueStatus,
isQueueingMessages: boolean,
unreadCounts: Map<number, number>;
nodesMap: Map<number, Protobuf.Mesh.NodeInfo>; // dont access directly, use getNodes, or getNode
dialog: {
import: boolean;
QR: boolean;
@ -81,16 +54,15 @@ 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;
@ -100,29 +72,27 @@ 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
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;
}
export interface DeviceState {
@ -140,6 +110,7 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
remoteDevices: new Map(),
addDevice: (id: number) => {
set(
produce<DeviceState>((draft) => {
draft.devices.set(id, {
@ -151,21 +122,12 @@ 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,
@ -178,11 +140,13 @@ 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(
@ -198,40 +162,16 @@ 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; }
}
}
}),
@ -241,66 +181,20 @@ 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; }
}
}
}),
@ -310,40 +204,30 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
set(
produce<DeviceState>((draft) => {
const device = draft.devices.get(id);
if (!device) {
return;
}
const workingConfigIndex = device?.workingConfig.findIndex(
if (!device) return;
const index = device.workingConfig.findIndex(
(wc) => wc.payloadVariant.case === config.payloadVariant.case,
);
if (workingConfigIndex !== -1) {
device.workingConfig[workingConfigIndex] = config;
if (index !== -1) {
device.workingConfig[index] = 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 workingModuleConfigIndex = device?.workingModuleConfig
.findIndex(
(wmc) =>
wmc.payloadVariant.case ===
moduleConfig.payloadVariant.case,
);
if (workingModuleConfigIndex !== -1) {
device.workingModuleConfig[workingModuleConfigIndex] =
moduleConfig;
if (!device) return;
const index = device.workingModuleConfig.findIndex(
(wmc) => wmc.payloadVariant.case === moduleConfig.payloadVariant.case,
);
if (index !== -1) {
device.workingModuleConfig[index] = moduleConfig;
} else {
device?.workingModuleConfig.push(moduleConfig);
device.workingModuleConfig.push(moduleConfig);
}
}),
);
@ -358,50 +242,6 @@ 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) => {
@ -426,10 +266,9 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
set(
produce<DeviceState>((draft) => {
const device = draft.devices.get(id);
if (!device) {
return;
if (device) {
device.channels.set(channel.index, channel);
}
device.channels.set(channel.index, channel);
}),
);
},
@ -438,12 +277,9 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
produce<DeviceState>((draft) => {
const device = draft.devices.get(id);
if (device) {
const waypointIndex = device.waypoints.findIndex(
(wp) => wp.id === waypoint.id,
);
if (waypointIndex !== -1) {
device.waypoints[waypointIndex] = waypoint;
const index = device.waypoints.findIndex((wp) => wp.id === waypoint.id);
if (index !== -1) {
device.waypoints[index] = waypoint;
} else {
device.waypoints.push(waypoint);
}
@ -452,13 +288,14 @@ 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.nodes.set(nodeInfo.num, nodeInfo);
if (!device) return;
device.nodesMap.set(nodeInfo.num, nodeInfo);
}),
);
},
@ -479,11 +316,11 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
if (!device) {
return;
}
const currentNode = device.nodes.get(user.from) ??
create(Protobuf.Mesh.NodeInfoSchema);
const currentNode = device.nodesMap.get(user.from) ?? create(Protobuf.Mesh.NodeInfoSchema);
currentNode.user = user.data;
device.nodes.set(user.from, currentNode);
}),
currentNode.num = user.from;
device.nodesMap.set(user.from, currentNode);
})
);
},
addPosition: (position) => {
@ -493,11 +330,11 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
if (!device) {
return;
}
const currentNode = device.nodes.get(position.from) ??
create(Protobuf.Mesh.NodeInfoSchema);
const currentNode = device.nodesMap.get(position.from) ?? create(Protobuf.Mesh.NodeInfoSchema);
currentNode.position = position.data;
device.nodes.set(position.from, currentNode);
}),
currentNode.num = position.from;
device.nodesMap.set(position.from, currentNode);
})
);
},
addConnection: (connection) => {
@ -510,39 +347,13 @@ 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) {
return;
if (device) {
device.metadata.set(from, metadata);
}
device.metadata.set(from, metadata);
}),
);
},
@ -550,17 +361,10 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
set(
produce<DeviceState>((draft) => {
const device = draft.devices.get(id);
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]);
}
if (!device) return;
const routes = device.traceroutes.get(traceroute.from) ?? [];
routes.push(traceroute);
device.traceroutes.set(traceroute.from, routes);
}),
);
},
@ -569,83 +373,38 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
produce<DeviceState>((draft) => {
const device = draft.devices.get(id);
if (!device) {
return;
return
}
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;
}),
);
}),
);
device.nodesMap.delete(nodeNum);
}))
},
setDialogOpen: (dialog: DialogVariant, open: boolean) => {
set(
produce<DeviceState>((draft) => {
const device = draft.devices.get(id);
if (!device) {
return;
if (device) {
device.dialog[dialog] = open;
}
device.dialog[dialog] = open;
}),
);
},
getDialogOpen: (dialog: DialogVariant) => {
const device = get().devices.get(id);
if (!device) {
throw new Error("Device not found");
}
if (!device) throw new Error(`Device ${id} 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.nodes.get(data.from);
if (!device) return;
const node = device.nodesMap.get(data.from);
if (node) {
device.nodes.set(data.from, {
...node,
lastHeard: data.time,
snr: data.snr,
});
node.lastHeard = data.time;
node.snr = data.snr;
device.nodesMap.set(data.from, node);
} else {
device.nodes.set(
device.nodesMap.set(
data.from,
create(Protobuf.Mesh.NodeInfoSchema, {
num: data.from,
@ -667,63 +426,98 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
}),
);
},
setQueueStatus: (status: QueueStatus) => {
setNodeError: (nodeNum, error) => {
set(
produce<DeviceState>((draft) => {
const device = draft.devices.get(id);
if (device) {
device.queueStatus = status;
device.queueStatus.free >= 10 ? true : false
device.nodeErrors.set(nodeNum, { node: nodeNum, error });
}
}),
);
},
setNodeError: (nodeNum, error) => {
clearNodeError: (nodeNum: number) => {
set(
produce<DeviceState>((draft) => {
const device = draft.devices.get(id);
if (device) {
device.nodeErrors.set(nodeNum, { node: nodeNum, error });
device.nodeErrors.delete(nodeNum);
}
}),
);
},
clearNodeError: (nodeNum: number) => {
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) {
device.nodeErrors.delete(nodeNum);
if (!device) return;
const currentCount = device.unreadCounts.get(nodeNum) ?? 0;
device.unreadCounts.set(nodeNum, currentCount + 1);
})
);
},
resetUnread: (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);
}
}),
})
);
},
getNodeError: (nodeNum: number) => {
getNodes: (filter?: (node: Protobuf.Mesh.NodeInfo) => boolean): Protobuf.Mesh.NodeInfo[] => {
const device = get().devices.get(id);
if (!device) {
throw new Error("Device not found");
return [];
}
return device.nodeErrors.get(nodeNum);
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;
},
hasNodeError: (nodeNum: number) => {
getNode: (nodeNum: number): Protobuf.Mesh.NodeInfo | undefined => {
const device = get().devices.get(id);
if (!device) {
throw new Error("Device not found");
return;
}
return device.nodeErrors.has(nodeNum);
if (!device.nodesMap.has(nodeNum)) {
return undefined;
}
return device.nodesMap.get(nodeNum);
},
getNodesLength: () => {
const device = get().devices.get(id);
if (!device) {
return 0;
}
return device.nodesMap.size
},
});
}),
);
const device = get().devices.get(id);
if (!device) {
throw new Error("Device not found");
throw new Error(`Failed to create or retrieve device with ID ${id}`);
}
return device;
},
removeDevice: (id) => {
set(
produce<DeviceState>((draft) => {

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

@ -0,0 +1,212 @@
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

@ -0,0 +1,486 @@
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

@ -0,0 +1,70 @@
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

@ -0,0 +1,37 @@
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

@ -0,0 +1,81 @@
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,15 +1,16 @@
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);
});
@ -51,6 +52,7 @@ export const subscribeAll = (
connection.events.onMyNodeInfo.subscribe((nodeInfo) => {
device.setHardware(nodeInfo);
messageStore.setNodeNum(nodeInfo.myNodeNum);
myNodeNum = nodeInfo.myNodeNum;
});
@ -63,10 +65,8 @@ export const subscribeAll = (
});
connection.events.onNodeInfoPacket.subscribe((nodeInfo) => {
// toast(`New Node Discovered: ${nodeInfo.user?.shortName ?? "UNK"}`, {
// icon: "🔎"
// });
device.addNodeInfo(nodeInfo);
const nodeWithUser = NodeInfoFactory.ensureDefaultUser(nodeInfo);
device.addNodeInfo(nodeWithUser);
});
connection.events.onChannelPacket.subscribe((channel) => {
@ -81,10 +81,22 @@ export const subscribeAll = (
connection.events.onMessagePacket.subscribe((messagePacket) => {
device.addMessage({
...messagePacket,
state: messagePacket.from !== myNodeNum ? "ack" : "waiting",
});
// 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);
}
}
});
connection.events.onTraceRoutePacket.subscribe((traceRoutePacket) => {
@ -105,13 +117,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,3 +29,43 @@ 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,6 +25,7 @@
--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,19 +31,20 @@ const ChannelsPage = () => {
return (
<>
<Sidebar />
<PageLayout
label={`Channel: ${
currentChannel ? getChannelName(currentChannel) : "Loading..."
}`}
leftBar={<Sidebar />}
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-800">
<TabsList className="dark:bg-slate-700">
{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 { useState } from "react";
import { useMemo, useState } from "react";
const ConfigPage = () => {
const { workingConfig, workingModuleConfig, connection } = useDevice();
@ -51,11 +51,14 @@ const ConfigPage = () => {
description:
`The configuration change ${moduleConfig.payloadVariant.case} has been saved.`,
})
)
),
);
setIsSaving(false);
}
await connection?.commitEditSettings();
} catch (_error) {
toast({
title: "Error Saving Config",
@ -66,10 +69,10 @@ const ConfigPage = () => {
}
};
return (
<>
const leftSidebar = useMemo(
() => (
<Sidebar>
<SidebarSection label="Config Sections">
<SidebarSection label="Modules">
<SidebarButton
label="Radio Config"
active={activeConfigSection === "device"}
@ -84,12 +87,20 @@ 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 dark:bg-white dark:text-slate-900 dark:hover:text-slate-100"
className="gap-2"
variant="default"
onClick={() => setConnectDialogOpen(true)}
>

265
src/pages/Map/FilterControl.tsx

@ -0,0 +1,265 @@
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,6 +18,8 @@ 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;
@ -33,7 +35,7 @@ const convertToLatLng = (position: {
});
const MapPage = () => {
const { nodes, waypoints } = useDevice();
const { getNodes, waypoints } = useDevice();
const { theme } = useTheme();
const { default: map } = useMap();
@ -46,13 +48,33 @@ const MapPage = () => {
// Filter out nodes without a valid position
const validNodes = useMemo(
() =>
Array.from(nodes.values()).filter(
getNodes(
(node): node is Protobuf.Mesh.NodeInfo =>
Boolean(node.position?.latitudeI),
),
[nodes],
[getNodes],
);
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();
@ -106,12 +128,12 @@ const MapPage = () => {
if (center) {
map.easeTo(center);
}
}, [validNodes, map]);
}, [filteredNodes, map]);
// Generate all markers
const markers = useMemo(
() =>
validNodes.map((node) => {
filteredNodes.map((node) => {
const position = convertToLatLng(node.position);
return (
<Marker
@ -128,7 +150,7 @@ const MapPage = () => {
</Marker>
);
}),
[validNodes, handleMarkerClick],
[filteredNodes, handleMarkerClick],
);
useEffect(() => {
@ -139,8 +161,7 @@ const MapPage = () => {
return (
<>
<Sidebar />
<PageLayout label="Map" noPadding actions={[]}>
<PageLayout label="Map" noPadding actions={[]} leftBar={<Sidebar />}>
<MapGl
mapStyle="https://raw.githubusercontent.com/hc-oss/maplibre-gl-styles/master/styles/osm-mapnik/v8/default.json"
attributionControl={false}
@ -191,12 +212,21 @@ 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

@ -0,0 +1,74 @@
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,163 +1,221 @@
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 { useState } from "react";
import { useCallback, useDeferredValue, useMemo, 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, nodes, hardware, messages, hasNodeError } = useDevice();
const { activeChat, chatType, setActiveChat, setChatType } = useAppStore();
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 [searchTerm, setSearchTerm] = useState<string>("");
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 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 allChannels = Array.from(channels.values());
const filteredChannels = allChannels.filter(
(ch) => ch.role !== Protobuf.Channel.Channel_Role.DISABLED,
);
const currentChannel = channels.get(activeChat);
const { toast } = useToast();
const node = nodes.get(activeChat);
const nodeHex = node?.num ? numberToHexUnpadded(node.num) : "Unknown";
const otherNode = getNode(activeChat);
const messageDestination = chatType === "direct" ? activeChat : "broadcast";
const messageChannel = chatType === "direct"
? Types.ChannelNumber.Primary
: activeChat;
const isDirect = chatType === MessageType.Direct;
const isBroadcast = chatType === MessageType.Broadcast;
return (
<>
<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>
)}
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 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]);
<div className="shrink-0 p-4 w-full dark:bg-slate-900">
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()}
<div className="flex-none dark:bg-slate-900 p-2">
{(isBroadcast || isDirect) ? (
<MessageInput
to={messageDestination}
channel={messageChannel}
to={isDirect ? activeChat : MessageType.Broadcast}
onSend={sendText}
maxBytes={200}
/>
</div>
</PageLayout>
) : (
<div className="p-4 text-center text-slate-400 italic">Select a chat to send a message.</div>
)}
</div>
</div>
</>
</PageLayout>
);
};

69
src/pages/Nodes.tsx

@ -1,7 +1,6 @@
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";
@ -11,30 +10,18 @@ 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, useEffect, useState } from "react";
import { type JSX, useCallback, useDeferredValue, 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 { nodes, hardware, connection } = useDevice();
const { getNodes, hardware, connection } = useDevice();
const [selectedNode, setSelectedNode] = useState<
Protobuf.Mesh.NodeInfo | undefined
>(undefined);
@ -45,12 +32,16 @@ const NodesPage = (): JSX.Element => {
Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery> | undefined
>();
const [searchTerm, setSearchTerm] = useState<string>("");
const deferredSearch = useDeferredValue(searchTerm);
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 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)
);
})
useEffect(() => {
if (!connection) return;
@ -85,18 +76,17 @@ const NodesPage = (): JSX.Element => {
return (
<>
<Sidebar />
<div className="flex flex-col w-full">
<div className="p-4">
<input
type="text"
<PageLayout label="" leftBar={<Sidebar />} className="flex flex-col w-full">
<div className="p-2">
<Input
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 h-full">
<div className="overflow-y-auto">
<Table
headings={[
{ title: "", type: "blank", sortable: false },
@ -110,7 +100,7 @@ const NodesPage = (): JSX.Element => {
]}
rows={filteredNodes.map((node) => [
<div key={node.num}>
<Avatar text={shortNameFromNode(node)} />
<Avatar text={node.user?.shortName ?? "UNK "} />
</div>,
<h1
key="longName"
@ -118,25 +108,17 @@ const NodesPage = (): JSX.Element => {
onKeyUp={(evt) => {
evt.key === "Enter" && setSelectedNode(node);
}}
className="cursor-pointer underline"
className="cursor-pointer underline ml-2 whitespace-break-spaces"
tabIndex={0}
role="button"
>
{node.user?.longName ??
(node.user?.macaddr
? `Meshtastic ${
base16
.stringify(node.user?.macaddr.subarray(4, 6) ?? [])
.toLowerCase()
}`
: `!${numberToHexUnpadded(node.num)}`)}
{node.user?.longName ?? numberToHexUnpadded(node.num)}
</h1>,
<Mono key="hops">
<Mono key="hops" className="w-16">
{node.lastHeard !== 0
? node.viaMqtt === false && node.hopsAway === 0
? "Direct"
: `${node.hopsAway?.toString()} ${
node.hopsAway > 1 ? "hops" : "hop"
: `${node.hopsAway?.toString()} ${node.hopsAway ?? 0 > 1 ? "hops" : "hop"
} away`
: "-"}
{node.viaMqtt === true ? ", via MQTT" : ""}
@ -183,8 +165,7 @@ const NodesPage = (): JSX.Element => {
onOpenChange={() => setSelectedLocation(undefined)}
/>
</div>
<Footer />
</div>
</PageLayout>
</>
);
};

18
src/tests/setupTests.ts

@ -1,12 +1,20 @@
import { expect, afterEach } from 'vitest';
import { afterEach, vi } from 'vitest';
import { cleanup } from '@testing-library/react';
import * as matchers from '@testing-library/jest-dom/matchers';
import { enableMapSet } from "immer";
import "@testing-library/jest-dom";
import '@testing-library/user-event';
// Enable auto mocks for our UI components
//vi.mock('@components/UI/Dialog.tsx');
//vi.mock('@components/UI/Typography/Link.tsx');
enableMapSet();
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,6 +32,10 @@ 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