Remove duplicate node logic, UI Update, flatten build output (#586)
* refactor nodes to getNodes fn. ui updates * fixed several styling issues * fix: message specific styling/overflow * added footer, fixed tests. styling * fix: added theme support back to app component * fix: hide emojis/reactions * fix: added more padding to content element * fix: fixed padding in content element * updated color scheme * fix: more dark mode styling improvements * fix: padding and alignment fixes * fix: prevent left sidebar collapse, added battery component * fix: change scrollbars to "tiny" style, improved message scrolling, fixed bug with message input * message store fixes, ui fixes * fix: disabled message persistance until after release
This commit is contained in:
@@ -6468,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",
|
||||
|
||||
@@ -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 |
+11
-8
@@ -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 >
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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()
|
||||
}
|
||||
/>
|
||||
@@ -222,7 +222,7 @@ export const CommandPalette = () => {
|
||||
label: "Clear All Stored Message",
|
||||
icon: EraserIcon,
|
||||
action() {
|
||||
setDialogOpen("clearMessages", true);
|
||||
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)
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
@@ -1,9 +1,10 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useMessageStore } from "@core/stores/messageStore.ts";
|
||||
// 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.ts', () => ({
|
||||
vi.mock('@core/stores/messageStore', () => ({
|
||||
useMessageStore: vi.fn(() => ({
|
||||
deleteAllMessages: vi.fn(),
|
||||
})),
|
||||
@@ -14,17 +15,36 @@ describe('DeleteMessagesDialog', () => {
|
||||
const mockClearAllMessages = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(useMessageStore).mockReturnValue({ deleteAllMessages: mockClearAllMessages });
|
||||
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')).toBeVisible();
|
||||
expect(screen.getByText(/This action will clear all message history./)).toBeVisible();
|
||||
expect(screen.getByRole('button', { name: 'Dismiss' })).toBeVisible();
|
||||
expect(screen.getByRole('button', { name: 'Clear Messages' })).toBeVisible();
|
||||
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', () => {
|
||||
@@ -32,15 +52,10 @@ describe('DeleteMessagesDialog', () => {
|
||||
expect(screen.queryByText('Clear All Messages')).toBeNull();
|
||||
});
|
||||
|
||||
it('calls onOpenChange with false when the close button is clicked', () => {
|
||||
render(<DeleteMessagesDialog open={true} onOpenChange={mockOnOpenChange} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
|
||||
expect(mockOnOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -48,6 +63,7 @@ describe('DeleteMessagesDialog', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@components/UI/Dialog.tsx";
|
||||
import { AlertTriangleIcon } from "lucide-react";
|
||||
import { useMessageStore } from "@core/stores/messageStore.ts";
|
||||
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
|
||||
|
||||
export interface DeleteMessagesDialogProps {
|
||||
open: boolean;
|
||||
@@ -29,7 +29,7 @@ export const DeleteMessagesDialog = ({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogClose onClick={handleCloseDialog} />
|
||||
<DialogClose data-testid="dialog-close-button" />
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangleIcon className="h-5 w-5 text-warning" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
};
|
||||
@@ -109,7 +109,6 @@ export const ImportDialog = ({
|
||||
<Input
|
||||
value={importDialogInput}
|
||||
suffix={validUrl ? "✅" : "❌"}
|
||||
className="dark:text-slate-900"
|
||||
onChange={(e) => {
|
||||
setImportDialogInput(e.target.value);
|
||||
}}
|
||||
|
||||
@@ -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 ??
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -13,7 +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.ts";
|
||||
import { MessageType, useMessageStore } from "../../core/stores/messageStore/index.ts";
|
||||
|
||||
export interface NodeOptionsDialogProps {
|
||||
node: Protobuf.Mesh.NodeInfo | undefined;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,102 +1,97 @@
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi, Mock } from "vitest";
|
||||
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 { useMessageStore } from "@core/stores/messageStore.ts"; // Import for mocking
|
||||
import { useDevice } from "@core/stores/deviceStore.ts"; // Import for mocking
|
||||
import { expect, test, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
|
||||
vi.mock("@core/stores/messageStore");
|
||||
vi.mock("./useRefreshKeysDialog");
|
||||
|
||||
vi.mock("@core/stores/messageStore.ts", () => ({
|
||||
useMessageStore: vi.fn(),
|
||||
}));
|
||||
const mockUseMessageStore = vi.mocked(useMessageStore);
|
||||
const mockUseRefreshKeysDialog = vi.mocked(useRefreshKeysDialog);
|
||||
|
||||
const mockNodeWithError: Partial<Protobuf.Mesh.NodeInfo> = {
|
||||
user: { longName: "Test Node Long", shortName: "TNL", id: 456 },
|
||||
};
|
||||
const mockNodes = new Map([[456, mockNodeWithError]]);
|
||||
const mockNodeErrors = new Map([[123, { node: 456 }]]);
|
||||
const getInitialState = () => useDeviceStore.getInitialState?.() ?? { devices: new Map(), remoteDevices: new Map() };
|
||||
|
||||
vi.mock("@core/stores/deviceStore.ts", () => ({
|
||||
useDevice: vi.fn(),
|
||||
}));
|
||||
beforeEach(() => {
|
||||
useDeviceStore.setState(getInitialState(), true);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const mockHandleCloseDialog = vi.fn();
|
||||
const mockHandleNodeRemove = vi.fn();
|
||||
vi.mock("./useRefreshKeysDialog.ts", () => ({
|
||||
useRefreshKeysDialog: vi.fn(() => ({
|
||||
handleCloseDialog: mockHandleCloseDialog,
|
||||
handleNodeRemove: mockHandleNodeRemove,
|
||||
})),
|
||||
}));
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("RefreshKeysDialog Component", () => {
|
||||
let onOpenChangeMock: Mock;
|
||||
test("renders dialog when there is a node error for the active chat", () => {
|
||||
const deviceId = 1;
|
||||
const nodeWithErrorNum = 12345;
|
||||
const activeChatNum = nodeWithErrorNum;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
onOpenChangeMock = vi.fn();
|
||||
const deviceStore = useDeviceStore.getState().addDevice(deviceId);
|
||||
|
||||
vi.mocked(useMessageStore).mockReturnValue({ activeChat: 123 });
|
||||
vi.mocked(useDevice).mockReturnValue({
|
||||
nodeErrors: mockNodeErrors,
|
||||
nodes: mockNodes,
|
||||
});
|
||||
vi.mocked(useRefreshKeysDialog).mockReturnValue({
|
||||
handleCloseDialog: mockHandleCloseDialog,
|
||||
handleNodeRemove: mockHandleNodeRemove,
|
||||
});
|
||||
deviceStore.addNodeInfo({
|
||||
num: nodeWithErrorNum,
|
||||
user: {
|
||||
id: nodeWithErrorNum.toString(),
|
||||
publicKey: new Uint8Array(0),
|
||||
hwModel: Protobuf.Mesh.HardwareModel.HELTEC_V3,
|
||||
longName: "Problem Node Long",
|
||||
shortName: "ProbNode",
|
||||
isLicensed: false,
|
||||
macaddr: new Uint8Array(0)
|
||||
},
|
||||
lastHeard: Date.now() / 1000,
|
||||
snr: 10
|
||||
} as Protobuf.Mesh.NodeInfo);
|
||||
|
||||
deviceStore.setNodeError(activeChatNum, "PKI_MISMATCH");
|
||||
|
||||
const updatedDeviceState = useDeviceStore.getState().getDevice(deviceId);
|
||||
if (!updatedDeviceState) {
|
||||
throw new Error("Failed to get updated device state from store for provider");
|
||||
}
|
||||
|
||||
mockUseMessageStore.mockReturnValue({ activeChat: activeChatNum });
|
||||
const mockHandleClose = vi.fn();
|
||||
const mockHandleRemove = vi.fn();
|
||||
mockUseRefreshKeysDialog.mockReturnValue({
|
||||
handleCloseDialog: mockHandleClose,
|
||||
handleNodeRemove: mockHandleRemove,
|
||||
});
|
||||
|
||||
it("should render the dialog with dynamic content when open and data is available", () => {
|
||||
render(<RefreshKeysDialog open onOpenChange={onOpenChangeMock} />);
|
||||
render(
|
||||
<DeviceContext.Provider value={updatedDeviceState}>
|
||||
<RefreshKeysDialog open onOpenChange={vi.fn()} />
|
||||
</DeviceContext.Provider>
|
||||
);
|
||||
|
||||
expect(screen.getByText(`Keys Mismatch - ${mockNodeWithError?.user?.longName}`)).toBeInTheDocument();
|
||||
expect(screen.getByText(new RegExp(`${mockNodeWithError?.user?.longName}.*${mockNodeWithError?.user?.shortName}`))).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /request new keys/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /dismiss/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Close/i })).toBeInTheDocument();
|
||||
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(),
|
||||
});
|
||||
|
||||
it("should call handleNodeRemove when 'Request New Keys' button is clicked", () => {
|
||||
render(<RefreshKeysDialog open onOpenChange={onOpenChangeMock} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /request new keys/i }));
|
||||
expect(mockHandleNodeRemove).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
const { container } = render(
|
||||
<DeviceContext.Provider value={currentDeviceState}>
|
||||
<RefreshKeysDialog open onOpenChange={vi.fn()} />
|
||||
</DeviceContext.Provider>
|
||||
);
|
||||
|
||||
it("should call handleCloseDialog when 'Dismiss' button is clicked", () => {
|
||||
render(<RefreshKeysDialog open onOpenChange={onOpenChangeMock} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /dismiss/i }));
|
||||
expect(mockHandleCloseDialog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should call handleCloseDialog when the explicit DialogClose button is clicked", () => {
|
||||
render(<RefreshKeysDialog open onOpenChange={onOpenChangeMock} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /close/i })); // Use the aria-label
|
||||
expect(mockHandleCloseDialog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
it("should not render the dialog when open is false", () => {
|
||||
render(<RefreshKeysDialog open={false} onOpenChange={onOpenChangeMock} />);
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render null if nodeErrorNum is not found for activeChat", () => {
|
||||
vi.mocked(useDevice).mockReturnValue({
|
||||
nodeErrors: new Map(),
|
||||
nodes: mockNodes,
|
||||
});
|
||||
const { container } = render(<RefreshKeysDialog open onOpenChange={onOpenChangeMock} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("should render null if nodeWithError is not found for nodeErrorNum.node", () => {
|
||||
vi.mocked(useDevice).mockReturnValue({
|
||||
nodeErrors: mockNodeErrors,
|
||||
nodes: new Map(),
|
||||
});
|
||||
const { container } = render(<RefreshKeysDialog open onOpenChange={onOpenChangeMock} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ 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.ts";
|
||||
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
|
||||
|
||||
export interface RefreshKeysDialogProps {
|
||||
open: boolean;
|
||||
@@ -18,22 +18,16 @@ export interface RefreshKeysDialogProps {
|
||||
|
||||
export const RefreshKeysDialog = ({ open, onOpenChange }: RefreshKeysDialogProps) => {
|
||||
const { activeChat } = useMessageStore();
|
||||
const { nodeErrors, nodes } = useDevice();
|
||||
const { nodeErrors, getNode } = useDevice();
|
||||
const { handleCloseDialog, handleNodeRemove } = useRefreshKeysDialog();
|
||||
|
||||
const nodeErrorNum = nodeErrors.get(activeChat);
|
||||
|
||||
if (!nodeErrorNum) {
|
||||
console.error("Node with error not found");
|
||||
return null;
|
||||
}
|
||||
|
||||
const nodeWithError = nodes.get(nodeErrorNum?.node ?? 0);
|
||||
|
||||
if (!nodeWithError) {
|
||||
console.error("Node with error not found");
|
||||
return null;
|
||||
}
|
||||
const nodeWithError = getNode(nodeErrorNum.node);
|
||||
|
||||
const text = {
|
||||
title: `Keys Mismatch - ${nodeWithError?.user?.longName ?? ""}`,
|
||||
|
||||
@@ -2,12 +2,12 @@ import { renderHook, act } from "@testing-library/react";
|
||||
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
|
||||
import { beforeEach, describe, expect, it, Mock, vi } from "vitest";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { useMessageStore } from "@core/stores/messageStore/index.ts";
|
||||
|
||||
vi.mock("@core/stores/messageStore.ts", () => ({
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,28 +1,27 @@
|
||||
import { useCallback } from "react";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { useMessageStore } from "@core/stores/messageStore.ts";
|
||||
import { useMessageStore } from "@core/stores/messageStore/index.ts";
|
||||
|
||||
export function useRefreshKeysDialog() {
|
||||
const { removeNode, setDialogOpen, clearNodeError, getNodeError } = useDevice();
|
||||
const { activeChat } = useMessageStore();
|
||||
|
||||
const handleCloseDialog = useCallback(() => {
|
||||
setDialogOpen('refreshKeys', false);
|
||||
}, [setDialogOpen]);
|
||||
|
||||
const handleNodeRemove = useCallback(() => {
|
||||
const nodeWithError = getNodeError(activeChat);
|
||||
if (!nodeWithError) {
|
||||
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
|
||||
};
|
||||
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ?? []).map(snr => snr / 4);
|
||||
const snrBack = (traceroute?.data.snrBack ?? []).map(snr => snr / 4);
|
||||
const from = nodes.get(traceroute?.from ?? 0);
|
||||
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>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Input } from "@components/UI/Input.tsx";
|
||||
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";
|
||||
@@ -15,6 +16,7 @@ export interface InputFieldProps<T> extends BaseFormBuilderProps<T> {
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
step?: number;
|
||||
className?: string;
|
||||
fieldLength?: {
|
||||
min?: number;
|
||||
max?: number;
|
||||
@@ -63,6 +65,7 @@ export function GenericInput<T extends FieldValues>({
|
||||
onChange={handleInputChange}
|
||||
showCopyButton={field.properties?.showCopyButton}
|
||||
showPasswordToggle={field.properties?.showPasswordToggle || field.type === "password"}
|
||||
className={field.properties?.className}
|
||||
{...restProperties}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
@@ -24,7 +24,7 @@ export const FieldWrapper = ({
|
||||
<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="max-w-3xl">
|
||||
<p className="text-sm text-slate-400">{description}</p>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">{description}</p>
|
||||
<p hidden={valid ?? true} className="text-sm text-red-500">
|
||||
{validationText}
|
||||
</p>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -270,11 +270,6 @@ export const Security = () => {
|
||||
properties: {
|
||||
value: state.adminKey,
|
||||
showCopyButton: true,
|
||||
action: {
|
||||
icon: state.adminKeyVisible ? EyeOff : Eye,
|
||||
onClick: () =>
|
||||
dispatch({ type: "TOGGLE_ADMIN_KEY_VISIBILITY" }),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -7,7 +7,7 @@ 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.ts";
|
||||
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
|
||||
|
||||
export const BLE = ({ setConnectionInProgress, closeDialog }: TabElementProps) => {
|
||||
const [bleDevices, setBleDevices] = useState<BluetoothDevice[]>([]);
|
||||
@@ -43,7 +43,7 @@ export const BLE = ({ setConnectionInProgress, closeDialog }: TabElementProps) =
|
||||
{bleDevices.map((device) => (
|
||||
<Button
|
||||
key={device.id}
|
||||
className="dark:bg-slate-900 dark:text-white"
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
setConnectionInProgress(true);
|
||||
onConnect(device);
|
||||
@@ -57,8 +57,10 @@ export const BLE = ({ setConnectionInProgress, closeDialog }: TabElementProps) =
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
className="dark:bg-slate-900 dark:text-white"
|
||||
variant="default"
|
||||
onClick={async () => {
|
||||
|
||||
|
||||
await navigator.bluetooth
|
||||
.requestDevice({
|
||||
filters: [{ services: [ServiceUuid] }],
|
||||
|
||||
@@ -13,7 +13,7 @@ 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.ts";
|
||||
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
|
||||
|
||||
interface FormData {
|
||||
ip: string;
|
||||
@@ -74,7 +74,7 @@ export const HTTP = ({ closeDialog, setConnectionInProgress, connectionInProgres
|
||||
<Input
|
||||
prefix={tlsValue ? "https://" : "http://"}
|
||||
placeholder="000.000.000.000 / meshtastic.local"
|
||||
className="text-slate-900 dark:text-slate-900"
|
||||
className="text-slate-900 dark:text-slate-100"
|
||||
{...register("ip")}
|
||||
/>
|
||||
</div>
|
||||
@@ -120,7 +120,7 @@ export const HTTP = ({ closeDialog, setConnectionInProgress, connectionInProgres
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
className="dark:bg-slate-900 dark:text-white"
|
||||
variant={"default"}
|
||||
>
|
||||
<span>{connectionInProgress ? "Connecting..." : "Connect"}</span>
|
||||
</Button>
|
||||
|
||||
@@ -8,7 +8,7 @@ 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.ts";
|
||||
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
|
||||
|
||||
export const Serial = ({ setConnectionInProgress, closeDialog }: TabElementProps) => {
|
||||
const [serialPorts, setSerialPorts] = useState<SerialPort[]>([]);
|
||||
@@ -52,7 +52,7 @@ export const Serial = ({ setConnectionInProgress, 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);
|
||||
@@ -68,7 +68,7 @@ export const Serial = ({ setConnectionInProgress, 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));
|
||||
|
||||
@@ -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,
|
||||
@@ -28,7 +23,8 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@radix-ui/react-tooltip";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { MessageType, useMessageStore } from "@core/stores/messageStore.ts";
|
||||
import { MessageType, useMessageStore } from "../../../core/stores/messageStore/index.ts";
|
||||
import BatteryStatus from "@components/BatteryStatus.tsx";
|
||||
|
||||
export interface NodeDetailProps {
|
||||
node: ProtobufType.Mesh.NodeInfo;
|
||||
@@ -37,7 +33,7 @@ export interface NodeDetailProps {
|
||||
export const NodeDetail = ({ node }: NodeDetailProps) => {
|
||||
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 =
|
||||
@@ -50,10 +46,10 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
|
||||
}
|
||||
|
||||
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
|
||||
@@ -113,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">
|
||||
@@ -198,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>
|
||||
@@ -206,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)}%
|
||||
|
||||
@@ -1,72 +1,77 @@
|
||||
import { MessageItem } from "@components/PageComponents/Messages/MessageItem.tsx";
|
||||
import type { Message as MessageType } from "@core/stores/messageStore.ts";
|
||||
import { InboxIcon } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { Message } from "@core/stores/messageStore/types.ts";
|
||||
|
||||
export interface ChannelChatProps {
|
||||
messages?: MessageType[];
|
||||
messages?: Message[];
|
||||
}
|
||||
|
||||
const EmptyState = () => (
|
||||
<div className="flex flex-col place-content-center place-items-center p-8 text-gray-500 dark:text-gray-400">
|
||||
<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) => {
|
||||
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(() => {
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
if (scrollContainer) {
|
||||
const isNearBottom =
|
||||
scrollContainer.scrollHeight -
|
||||
scrollContainer.scrollTop -
|
||||
scrollContainer.clientHeight <
|
||||
100;
|
||||
|
||||
if (isNearBottom) {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}
|
||||
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => {
|
||||
requestAnimationFrame(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior });
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [scrollToBottom, messages]);
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
if (!scrollContainer) return;
|
||||
const isScrolledToBottom = scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight <= 10;
|
||||
|
||||
if (isScrolledToBottom || !userScrolledUpRef.current) {
|
||||
scrollToBottom('smooth');
|
||||
}
|
||||
}, [messages, scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
const handleScroll = () => {
|
||||
if (!scrollContainer) return;
|
||||
const isAtBottom = scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight <= 10;
|
||||
userScrolledUpRef.current = !isAtBottom;
|
||||
};
|
||||
scrollContainer?.addEventListener('scroll', handleScroll, { passive: true });
|
||||
return () => {
|
||||
scrollContainer?.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!messages?.length) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<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">
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="flex-1 overflow-y-auto py-4"
|
||||
>
|
||||
<div className="flex flex-col justify-end min-h-full space-y-4">
|
||||
{messages?.map((message) => {
|
||||
return (
|
||||
<MessageItem
|
||||
key={message?.messageId}
|
||||
message={message}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<div ref={messagesEndRef} className="h-0 w-full" />
|
||||
</div>
|
||||
</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 ref={messagesEndRef} className="h-px" />
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
@@ -18,7 +18,7 @@ export const MessageActionsMenu = ({
|
||||
onReply
|
||||
}: MessageActionsMenuProps) => {
|
||||
const hoverIconBarClass = cn(
|
||||
"absolute top-2 right-4",
|
||||
"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",
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import { MessageInput } from './MessageInput.tsx';
|
||||
import { useDevice } from '@core/stores/deviceStore.ts';
|
||||
import { useMessageStore } from '@core/stores/messageStore.ts';
|
||||
import { debounce } from '@core/utils/debounce.ts';
|
||||
import { Types } from "@meshtastic/core";
|
||||
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 }) => (
|
||||
<button type={type} className={className} onClick={onClick} onSubmit={onSubmit}>
|
||||
Button: vi.fn(({ type, className, children, onClick, onSubmit, variant, ...rest }) => (
|
||||
<button type={type} className={className} onClick={onClick} onSubmit={onSubmit} {...rest}>
|
||||
{children}
|
||||
</button>
|
||||
)),
|
||||
@@ -23,16 +20,21 @@ vi.mock('@components/UI/Input.tsx', () => ({
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
data-testid="message-input-field"
|
||||
/>
|
||||
)),
|
||||
}));
|
||||
|
||||
vi.mock('@core/stores/deviceStore.ts', () => ({
|
||||
useDevice: vi.fn(),
|
||||
}));
|
||||
const mockSetDraft = vi.fn();
|
||||
const mockGetDraft = vi.fn();
|
||||
const mockClearDraft = vi.fn();
|
||||
|
||||
vi.mock('@core/stores/messageStore.ts', () => ({
|
||||
useMessageStore: vi.fn(),
|
||||
vi.mock('@core/stores/messageStore', () => ({
|
||||
useMessageStore: vi.fn(() => ({
|
||||
setDraft: mockSetDraft,
|
||||
getDraft: mockGetDraft,
|
||||
clearDraft: mockClearDraft,
|
||||
})),
|
||||
MessageState: {
|
||||
Ack: 'ack',
|
||||
Waiting: 'waiting',
|
||||
@@ -44,111 +46,177 @@ vi.mock('@core/stores/messageStore.ts', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@core/utils/debounce.ts', () => ({
|
||||
debounce: vi.fn((fn) => fn),
|
||||
}));
|
||||
|
||||
vi.mock('lucide-react', () => ({
|
||||
SendIcon: vi.fn(() => <svg data-testid="send-icon" />),
|
||||
}));
|
||||
|
||||
describe('MessageInput', () => {
|
||||
const mockSetMessageState = vi.fn();
|
||||
const mockSetActiveChat = vi.fn();
|
||||
const mockSetDraft = vi.fn();
|
||||
const mockGetDraft = vi.fn();
|
||||
const mockClearDraft = vi.fn();
|
||||
const mockSendText = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
(useDevice as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
connection: {
|
||||
sendText: mockSendText,
|
||||
},
|
||||
});
|
||||
|
||||
(useMessageStore as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
setMessageState: mockSetMessageState,
|
||||
activeChat: 123,
|
||||
setDraft: mockSetDraft,
|
||||
getDraft: mockGetDraft,
|
||||
clearDraft: mockClearDraft,
|
||||
});
|
||||
|
||||
mockSetMessageState.mockClear();
|
||||
mockSetActiveChat.mockClear();
|
||||
mockSetDraft.mockClear();
|
||||
mockGetDraft.mockClear();
|
||||
mockClearDraft.mockClear();
|
||||
mockSendText.mockClear();
|
||||
(debounce as ReturnType<typeof vi.fn>).mockImplementation((fn) => fn);
|
||||
});
|
||||
|
||||
const renderComponent = (props: { to: Types.Destination; channel: Types.ChannelNumber; maxBytes: number }) => {
|
||||
render(<MessageInput {...props} />);
|
||||
const mockOnSend = vi.fn();
|
||||
const defaultProps: MessageInputProps = {
|
||||
onSend: mockOnSend,
|
||||
to: 123,
|
||||
maxBytes: 256,
|
||||
};
|
||||
|
||||
it.skip('sends text message and updates state to Ack on submit', async () => {
|
||||
renderComponent({ to: 2, channel: 3, maxBytes: 256 });
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockGetDraft.mockReturnValue('');
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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;
|
||||
fireEvent.change(inputElement, { target: { value: 'Hello' } });
|
||||
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('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;
|
||||
|
||||
fireEvent.change(inputElement, { target: { value: testMessage } });
|
||||
|
||||
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('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';
|
||||
|
||||
fireEvent.change(inputElement, { target: { value: initialValue } });
|
||||
expect((inputElement as HTMLInputElement).value).toBe(initialValue);
|
||||
expect(mockSetDraft).toHaveBeenCalledWith(defaultProps.to, initialValue);
|
||||
mockSetDraft.mockClear();
|
||||
|
||||
fireEvent.change(inputElement, { target: { value: excessiveValue } });
|
||||
|
||||
expect((inputElement as HTMLInputElement).value).toBe(initialValue);
|
||||
expect(screen.getByTestId('byte-counter')).toHaveTextContent(`${smallMaxBytes}/${smallMaxBytes}`);
|
||||
expect(mockSetDraft).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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(mockSendText).toHaveBeenCalledWith('Hello', 2, true, 3);
|
||||
expect(mockSetMessageState).toHaveBeenCalledWith({
|
||||
type: 'direct',
|
||||
key: 123,
|
||||
messageId: undefined,
|
||||
newState: 'ack',
|
||||
});
|
||||
expect(mockClearDraft).toHaveBeenCalledWith(2);
|
||||
expect(inputElement.value).toBe('');
|
||||
expect(screen.getByTestId('byte-counter')).toHaveTextContent('0/256');
|
||||
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 broadcast message if to is "broadcast" and updates state to Ack', async () => {
|
||||
renderComponent({ to: 'broadcast', channel: 5, maxBytes: 256 });
|
||||
const inputElement = screen.getByPlaceholderText('Enter Message') as HTMLInputElement;
|
||||
fireEvent.change(inputElement, { target: { value: 'Broadcast message' } });
|
||||
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!';
|
||||
|
||||
fireEvent.change(inputElement, { target: { value: testMessageWithWhitespace } });
|
||||
fireEvent.submit(formElement);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSendText).toHaveBeenCalledWith('Broadcast message', 'broadcast', true, 5);
|
||||
expect(mockSetMessageState).toHaveBeenCalledWith({
|
||||
type: 'broadcast',
|
||||
key: 123,
|
||||
messageId: undefined,
|
||||
newState: 'ack',
|
||||
});
|
||||
expect(mockClearDraft).toHaveBeenCalledWith('broadcast');
|
||||
expect(inputElement.value).toBe('');
|
||||
expect(screen.getByTestId('byte-counter')).toHaveTextContent('0/256');
|
||||
expect(mockOnSend).toHaveBeenCalledTimes(1);
|
||||
expect(mockOnSend).toHaveBeenCalledWith(expectedTrimmedMessage);
|
||||
expect(mockClearDraft).toHaveBeenCalledWith(defaultProps.to);
|
||||
});
|
||||
});
|
||||
|
||||
it('updates state to Failed if sendText throws an error', async () => {
|
||||
mockSendText.mockRejectedValue({ id: 456 });
|
||||
renderComponent({ to: 3, channel: 1, maxBytes: 256 });
|
||||
const inputElement = screen.getByPlaceholderText('Enter Message') as HTMLInputElement;
|
||||
fireEvent.change(inputElement, { target: { value: 'Error message' } });
|
||||
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((inputElement as HTMLInputElement).value).toBe('');
|
||||
|
||||
fireEvent.submit(formElement);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSendText).toHaveBeenCalledWith('Error message', 3, true, 1);
|
||||
expect(mockSetMessageState).toHaveBeenCalledWith({
|
||||
type: 'direct',
|
||||
key: 123,
|
||||
messageId: 456,
|
||||
newState: 'failed',
|
||||
});
|
||||
expect(mockClearDraft).toHaveBeenCalledWith(3);
|
||||
expect(inputElement.value).toBe('');
|
||||
expect(screen.getByTestId('byte-counter')).toHaveTextContent('0/256');
|
||||
await act(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
});
|
||||
|
||||
expect(mockOnSend).not.toHaveBeenCalled();
|
||||
expect(mockClearDraft).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not call onSend or clearDraft if input contains only whitespace on submit', async () => {
|
||||
renderComponent();
|
||||
const inputElement = screen.getByTestId('message-input-field');
|
||||
const formElement = screen.getByRole('form');
|
||||
const whitespaceMessage = ' \t ';
|
||||
|
||||
fireEvent.change(inputElement, { target: { value: whitespaceMessage } });
|
||||
expect((inputElement as HTMLInputElement).value).toBe(whitespaceMessage);
|
||||
|
||||
fireEvent.submit(formElement);
|
||||
|
||||
await act(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
fireEvent.submit(formElement);
|
||||
|
||||
expect(mockOnSend).toHaveBeenCalledWith(newMessage);
|
||||
expect(mockClearDraft).toHaveBeenCalledWith(broadcastDest);
|
||||
});
|
||||
});
|
||||
@@ -1,54 +1,28 @@
|
||||
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 { MessageState, MessageType, useMessageStore } from "@core/stores/messageStore.ts";
|
||||
import { debounce } from "@core/utils/debounce.ts";
|
||||
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 } = useDevice();
|
||||
const { setMessageState, activeChat, setDraft, getDraft, clearDraft } = useMessageStore();
|
||||
|
||||
const [localDraft, setLocalDraft] = useState(getDraft(to));
|
||||
const [messageBytes, setMessageBytes] = useState(0);
|
||||
|
||||
const debouncedSetMessageDraft = useMemo(
|
||||
() => debounce((value: string) => setDraft(to, value), 300),
|
||||
[setDraft, to]
|
||||
);
|
||||
const { setDraft, getDraft, clearDraft } = useMessageStore();
|
||||
|
||||
const calculateBytes = (text: string) => new Blob([text]).size;
|
||||
|
||||
const chatType = to === MessageType.Broadcast ? MessageType.Broadcast : MessageType.Direct;
|
||||
|
||||
const sendText = useCallback(async (message: string) => {
|
||||
try {
|
||||
const messageId = await connection?.sendText(message, to, true, channel);
|
||||
if (messageId !== undefined) {
|
||||
setMessageState({ type: chatType, key: activeChat, messageId, newState: MessageState.Ack });
|
||||
}
|
||||
// deno-lint-ignore no-explicit-any
|
||||
} catch (e: any) {
|
||||
setMessageState({
|
||||
type: chatType,
|
||||
key: activeChat,
|
||||
messageId: e?.id,
|
||||
newState: MessageState.Failed,
|
||||
});
|
||||
}
|
||||
}, [channel, connection, setMessageState, to, activeChat, chatType]);
|
||||
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;
|
||||
@@ -56,27 +30,28 @@ export const MessageInput = ({
|
||||
|
||||
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(() => {
|
||||
sendText(localDraft.trim());
|
||||
onSend(localDraft.trim());
|
||||
setLocalDraft("");
|
||||
clearDraft(to);
|
||||
setMessageBytes(0);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<form className="w-full" action="#" name="messageInput" onSubmit={handleSubmit}>
|
||||
<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
|
||||
@@ -88,13 +63,13 @@ export const MessageInput = ({
|
||||
/>
|
||||
</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"
|
||||
variant="default"
|
||||
>
|
||||
<SendIcon size={16} />
|
||||
</Button>
|
||||
@@ -102,4 +77,4 @@ export const MessageInput = ({
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
@@ -5,146 +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/stores/messageStore.ts";
|
||||
import { Protobuf } from "@meshtastic/js";
|
||||
import { MessageActionsMenu } from "@components/PageComponents/Messages/MessageActionsMenu.tsx";
|
||||
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 {
|
||||
message: Message;
|
||||
// locale?: string; // locale
|
||||
}
|
||||
|
||||
interface MessageStatus {
|
||||
state: MessageState;
|
||||
interface MessageStatusInfo {
|
||||
displayText: string;
|
||||
icon: LucideIcon;
|
||||
ariaLabel: string;
|
||||
iconClassName?: string;
|
||||
}
|
||||
|
||||
const MESSAGE_STATUS: Record<MessageState, MessageStatus> = {
|
||||
[MessageState.Ack]: { state: MessageState.Ack, displayText: "Message delivered", icon: CheckCircle2, ariaLabel: "Message delivered" },
|
||||
[MessageState.Waiting]: { state: MessageState.Waiting, displayText: "Waiting for delivery", icon: CircleEllipsis, ariaLabel: "Sending message" },
|
||||
[MessageState.Failed]: { state: MessageState.Failed, displayText: "Delivery failed", icon: AlertCircle, ariaLabel: "Message delivery failed" },
|
||||
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: MessageState.Failed, displayText: "Unknown state", icon: AlertCircle, ariaLabel: "Message status unknown" };
|
||||
const UNKNOWN_STATUS: MessageStatusInfo = { displayText: "Unknown state", icon: AlertCircle, ariaLabel: "Message status unknown", iconClassName: "text-red-500 dark:text-red-400" };
|
||||
|
||||
const StatusTooltip = ({ status, children }: { status: MessageStatus; children: ReactNode }) => (
|
||||
const getMessageStatusInfo = (state: MessageState): MessageStatusInfo =>
|
||||
MESSAGE_STATUS_MAP[state] ?? UNKNOWN_STATUS;
|
||||
|
||||
const StatusTooltip = ({ statusInfo, children }: { statusInfo: MessageStatusInfo; children: ReactNode }) => (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
<TooltipContent className="bg-gray-800 text-white px-2 py-1 rounded text-xs">
|
||||
{status.displayText}
|
||||
<TooltipArrow className="fill-gray-800" />
|
||||
<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 }: { status: MessageStatus; className?: string }) => {
|
||||
const Icon = status.icon;
|
||||
const iconClass = cn("w-3.5 h-3.5 shrink-0", className);
|
||||
return (
|
||||
<StatusTooltip status={status}>
|
||||
<span aria-label={status.ariaLabel} role="img">
|
||||
<Icon className={iconClass} aria-hidden="true" />
|
||||
</span>
|
||||
</StatusTooltip>
|
||||
);
|
||||
};
|
||||
interface MessageItemProps {
|
||||
message: Message;
|
||||
}
|
||||
|
||||
const TimeDisplay = ({ date, className }: { date: number; className?: string }) => {
|
||||
const _date = useMemo(() => new Date(date), [date]);
|
||||
const locale = 'en-US'; // TODO: Make dynamic
|
||||
const formattedTime = useMemo(() => _date.toLocaleTimeString(locale, { hour: 'numeric', minute: '2-digit', hour12: true }), [_date, locale]);
|
||||
const fullDate = useMemo(() => _date.toLocaleString(locale, { dateStyle: 'medium', timeStyle: 'short' }), [_date, locale]);
|
||||
export const MessageItem = ({ message }: MessageItemProps) => {
|
||||
const { getNode } = useDevice();
|
||||
const { getMyNodeNum } = useMessageStore()
|
||||
|
||||
return (
|
||||
<time dateTime={_date.toISOString()} className={cn("text-xs", className)}>
|
||||
<span aria-hidden="true">{formattedTime}</span>
|
||||
<span className="sr-only">{fullDate}</span>
|
||||
</time>
|
||||
);
|
||||
};
|
||||
const messageUser: Protobuf.Mesh.NodeInfo | null | undefined = useMemo(() => {
|
||||
return message.from != null ? getNode(message.from) : null;
|
||||
}, [getNode, message.from]);
|
||||
|
||||
export const MessageItem = ({ message }: MessageProps) => {
|
||||
const { getDevices } = useDeviceStore();
|
||||
|
||||
const messageUser: Protobuf.Mesh.NodeInfo | null = useMemo(() => {
|
||||
if (message?.from === null || message?.from === undefined) return null;
|
||||
const devices = getDevices();
|
||||
for (const device of devices) {
|
||||
if (device.nodes.has(message.from)) {
|
||||
return device.nodes.get(message.from) ?? null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [getDevices, message.from]);
|
||||
|
||||
const { shortName, displayName } = useMemo(() => {
|
||||
const fallbackName = message.from
|
||||
const myNodeNum = useMemo(() => getMyNodeNum(), [getMyNodeNum]);
|
||||
const { displayName, shortName } = useMemo(() => {
|
||||
const userIdHex = message.from.toString(16).toUpperCase().padStart(2, '0');
|
||||
const last4 = userIdHex.slice(-4);
|
||||
const fallbackName = `Meshtastic ${last4}`
|
||||
const longName = messageUser?.user?.longName;
|
||||
const shortName = messageUser?.user?.shortName ?? fallbackName;
|
||||
const displayName = longName || fallbackName;
|
||||
return { shortName, displayName };
|
||||
const derivedShortName = messageUser?.user?.shortName || fallbackName;
|
||||
const derivedDisplayName = longName || derivedShortName;
|
||||
return { displayName: derivedDisplayName, shortName: derivedShortName };
|
||||
}, [messageUser, message.from]);
|
||||
|
||||
const messageStatus = getMessageStatus(message.state);
|
||||
const messageText = message?.message ?? "";
|
||||
const messageDate = message?.date;
|
||||
const isFailed = message.state === MessageState.Failed;
|
||||
const messageStatusInfo = getMessageStatusInfo(message.state);
|
||||
const StatusIconComponent = messageStatusInfo.icon;
|
||||
|
||||
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 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 px-4 py-2 relative list-none",
|
||||
"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 avatarSizeClass = "size-11";
|
||||
const gridGapClass = "gap-x-4";
|
||||
|
||||
const baseTextStyle = "text-sm text-gray-800 dark:text-gray-200";
|
||||
const nameTextStyle = "font-medium text-gray-900 dark:text-gray-100 mr-2";
|
||||
const dateTextStyle = "text-gray-500 dark:text-gray-400";
|
||||
const statusIconBaseColor = "text-gray-400 dark:text-gray-500";
|
||||
const statusIconFailedColor = "text-red-500 dark:text-red-400";
|
||||
|
||||
return (
|
||||
<li className={messageItemWrapperClass}>
|
||||
<div className={cn("grid grid-cols-[auto_1fr]", gridGapClass)}>
|
||||
<Avatar size="sm" text={shortName} className={cn(avatarSizeClass, "pt-0.5")} />
|
||||
<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-1.5 min-w-0">
|
||||
{messageDate != null ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={nameTextStyle} aria-hidden="true">
|
||||
{displayName}
|
||||
</span>
|
||||
<TimeDisplay date={messageDate} className={dateTextStyle} />
|
||||
<StatusIcon
|
||||
status={messageStatus}
|
||||
className={cn(isFailed ? statusIconFailedColor : statusIconBaseColor)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={cn(baseTextStyle, "whitespace-pre-wrap")}>
|
||||
{messageText}
|
||||
<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>
|
||||
</StatusTooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{message?.message && (
|
||||
<div className="text-sm text-slate-800 dark:text-slate-200 whitespace-pre-wrap break-words">
|
||||
{message.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<MessageActionsMenu
|
||||
onReply={() => console.log("Reply to message:", message.messageId)}
|
||||
/>
|
||||
{/* Actions Menu Placeholder */}
|
||||
{/* <div className="absolute top-1 right-1">
|
||||
<MessageActionsMenu onReply={() => console.log("Reply")} />
|
||||
</div> */}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
>
|
||||
<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">
|
||||
{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
|
||||
)}
|
||||
>
|
||||
{/* 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>
|
||||
);
|
||||
};
|
||||
};
|
||||
+196
-103
@@ -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;
|
||||
}
|
||||
|
||||
export const Sidebar = ({ children }: SidebarProps) => {
|
||||
const { hardware, nodes, metadata } = useDevice();
|
||||
const myNode = nodes.get(hardware.myNodeNum);
|
||||
const myMetadata = metadata.get(0);
|
||||
const { activePage, setActivePage, setDialogOpen } = useDevice();
|
||||
const [showSidebar, setShowSidebar] = useState<boolean>(true);
|
||||
interface NavLink {
|
||||
name: string;
|
||||
icon: LucideIcon;
|
||||
page: Page;
|
||||
}
|
||||
|
||||
interface NavLink {
|
||||
name: string;
|
||||
icon: LucideIcon;
|
||||
page: Page;
|
||||
}
|
||||
const CollapseToggleButton = () => {
|
||||
const { isCollapsed, toggleSidebar } = useSidebar();
|
||||
const buttonLabel = isCollapsed ? "Open sidebar" : "Close sidebar";
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={buttonLabel}
|
||||
onClick={toggleSidebar}
|
||||
className={cn(
|
||||
'absolute top-20 right-0 z-10 p-0.5 rounded-full transform translate-x-1/2',
|
||||
'transition-colors duration-300 ease-in-out',
|
||||
'border border-slate-300 dark:border-slate-200',
|
||||
'text-slate-500 dark:text-slate-200 hover:text-slate-400 dark:hover:text-slate-400',
|
||||
'focus:outline-none focus:ring-2 focus:ring-accent transition-transform'
|
||||
)}
|
||||
>
|
||||
<CircleChevronLeft
|
||||
size={24}
|
||||
className={cn(
|
||||
'transition-transform duration-300 ease-in-out',
|
||||
isCollapsed && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export const Sidebar = ({ children }: SidebarProps) => {
|
||||
const { hardware, getNode, getNodesLength, metadata, activePage, setActivePage, setDialogOpen } = useDevice();
|
||||
const { setCommandPaletteOpen } = useAppStore();
|
||||
const myNode = getNode(hardware.myNodeNum);
|
||||
const { isCollapsed } = useSidebar();
|
||||
const myMetadata = metadata.get(0);
|
||||
|
||||
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 (${Math.max(nodes.size - 1, 0)})`,
|
||||
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>
|
||||
<button
|
||||
type="button"
|
||||
className="transition-all hover:text-accent"
|
||||
onClick={() => setDialogOpen("deviceName", true)}
|
||||
<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'
|
||||
)}
|
||||
>
|
||||
<EditIcon size={16} />
|
||||
</button>
|
||||
<button type="button" onClick={() => setShowSidebar(false)}>
|
||||
<SidebarCloseIcon size={24} />
|
||||
</button>
|
||||
{myNode.user?.longName}
|
||||
</p>
|
||||
</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
|
||||
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="flex items-center">
|
||||
<ZapIcon size={24} viewBox="0 0 36 24" />
|
||||
<Subtle>
|
||||
{myNode.deviceMetrics?.voltage?.toPrecision(3) ?? "UNK"} volts
|
||||
</Subtle>
|
||||
<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="flex items-center">
|
||||
<CpuIcon size={24} viewBox="0 0 36 24" />
|
||||
<div className="inline-flex gap-2">
|
||||
<CpuIcon size={18} className="text-gray-500 dark:text-gray-400 w-4 flex-shrink-0" />
|
||||
<Subtle>v{myMetadata?.firmwareVersion ?? "UNK"}</Subtle>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center flex-shrink-0 ml-2',
|
||||
'transition-all duration-300 ease-in-out',
|
||||
isCollapsed
|
||||
? 'opacity-0 max-w-0 invisible pointer-events-none'
|
||||
: 'opacity-100 max-w-xs visible'
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Edit device name"
|
||||
className="p-1 rounded transition-colors hover:text-accent"
|
||||
onClick={() => setDialogOpen("deviceName", true)}
|
||||
>
|
||||
<PenLine size={22} />
|
||||
</button>
|
||||
<ThemeSwitcher />
|
||||
<button
|
||||
type="button"
|
||||
className="transition-all hover:text-accent"
|
||||
onClick={() => setCommandPaletteOpen(true)}
|
||||
>
|
||||
<SearchIcon />
|
||||
</button>
|
||||
</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,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("")}
|
||||
|
||||
@@ -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;
|
||||
@@ -16,7 +15,6 @@ interface AvatarProps {
|
||||
showError?: boolean;
|
||||
}
|
||||
|
||||
// biome-ignore lint/complexity/noStaticOnlyClass: stop being annoying Biome
|
||||
class ColorUtils {
|
||||
static hexToRgb(hex: number): RGBColor {
|
||||
return {
|
||||
@@ -42,47 +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 safeText = text?.toString().toUpperCase() ?? "UNK";
|
||||
const safeText = text?.toString().toUpperCase();
|
||||
const bgColor = getColorFromText(safeText);
|
||||
const isLight = ColorUtils.isLight(bgColor);
|
||||
const textColor = isLight ? "#000000" : "#FFFFFF";
|
||||
const initials = safeText.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,
|
||||
)}
|
||||
@@ -91,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>
|
||||
);
|
||||
};
|
||||
};
|
||||
@@ -8,13 +8,13 @@ const buttonVariants = cva(
|
||||
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:
|
||||
|
||||
@@ -71,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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -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}
|
||||
@@ -61,6 +61,7 @@ const DialogClose = ({
|
||||
}: DialogPrimitive.DialogCloseProps & React.RefAttributes<HTMLButtonElement> & { className?: string }) => (
|
||||
<DialogPrimitive.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}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -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>
|
||||
|
||||
+77
-55
@@ -1,16 +1,16 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { Check, Copy, Eye, EyeOff, 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 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-700 dark:text-slate-100 dark:focus:ring-slate-400 dark:focus:ring-offset-slate-600",
|
||||
"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",
|
||||
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",
|
||||
},
|
||||
@@ -26,7 +26,8 @@ type InputActionType = {
|
||||
icon: LucideIcon;
|
||||
onClick: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
ariaLabel: string;
|
||||
tooltip?: string;
|
||||
tooltip?: string
|
||||
condition?: boolean;
|
||||
};
|
||||
|
||||
export interface InputProps
|
||||
@@ -36,6 +37,7 @@ export interface InputProps
|
||||
suffix?: React.ReactNode;
|
||||
showPasswordToggle?: boolean;
|
||||
showCopyButton?: boolean;
|
||||
showClearButton?: boolean;
|
||||
containerClassName?: string;
|
||||
}
|
||||
|
||||
@@ -50,7 +52,9 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
suffix,
|
||||
showPasswordToggle,
|
||||
showCopyButton,
|
||||
showClearButton,
|
||||
value,
|
||||
onChange,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
@@ -58,10 +62,28 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
const { isVisible, toggleVisibility } = usePasswordVisibilityToggle();
|
||||
const { copy, isCopied } = useCopyToClipboard({ timeout: 1500 });
|
||||
|
||||
const actions: InputActionType[] = [];
|
||||
|
||||
if (showPasswordToggle && type === "password") {
|
||||
actions.push({
|
||||
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) => {
|
||||
@@ -70,10 +92,9 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
},
|
||||
ariaLabel: isVisible ? "Hide password" : "Show password",
|
||||
tooltip: isVisible ? "Hide password" : "Show password",
|
||||
});
|
||||
}
|
||||
if (showCopyButton) {
|
||||
actions.push({
|
||||
condition: !!showPasswordToggle && type === "password",
|
||||
},
|
||||
{
|
||||
id: "copy-value",
|
||||
icon: isCopied ? Check : Copy,
|
||||
onClick: (e) => {
|
||||
@@ -84,10 +105,14 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
},
|
||||
ariaLabel: isCopied ? "Copied!" : "Copy to clipboard",
|
||||
tooltip: isCopied ? "Copied!" : "Copy to clipboard",
|
||||
});
|
||||
}
|
||||
condition: !!showCopyButton,
|
||||
},
|
||||
];
|
||||
|
||||
const inputType = showPasswordToggle ? (isVisible ? "text" : "password") : type;
|
||||
const actions = potentialActions.filter(action => action.condition);
|
||||
|
||||
const inputType =
|
||||
showPasswordToggle ? (isVisible ? "text" : "password") : type;
|
||||
|
||||
const hasPrefix = !!prefix;
|
||||
const hasSuffix = !!suffix;
|
||||
@@ -95,16 +120,15 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
|
||||
const inputClassName = cn(
|
||||
inputVariants({ variant }),
|
||||
hasActions && !hasSuffix && "pr-10",
|
||||
hasPrefix && "rounded-l-none",
|
||||
(hasSuffix || hasActions) && "rounded-r-none border-r-0",
|
||||
className
|
||||
);
|
||||
|
||||
|
||||
return (
|
||||
<div className={cn("relative flex w-full items-stretch", containerClassName)}>
|
||||
{prefix && (
|
||||
<span className="inline-flex items-center rounded-l-md border 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">
|
||||
<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}
|
||||
</span>
|
||||
)}
|
||||
@@ -114,46 +138,44 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
className={inputClassName}
|
||||
ref={ref}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
{(hasSuffix || hasActions) && (
|
||||
<div className={cn(
|
||||
"flex items-stretch",
|
||||
!hasSuffix && hasActions && "border-y border-r border-slate-300 dark:border-slate-700 rounded-r-md"
|
||||
)}>
|
||||
{suffix && (
|
||||
<span className={cn(
|
||||
"inline-flex items-center border 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>
|
||||
)}
|
||||
{actions.length > 0 && (
|
||||
<div className={cn(
|
||||
"flex h-full items-center divide-x divide-slate-300 dark:divide-slate-700",
|
||||
!hasSuffix && "border-l border-slate-300 dark:border-slate-700"
|
||||
)}>
|
||||
{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",
|
||||
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 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,8 @@
|
||||
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;
|
||||
@@ -9,6 +12,7 @@ export interface SidebarButtonProps {
|
||||
children?: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
preventCollapse?: boolean;
|
||||
}
|
||||
|
||||
export const SidebarButton = ({
|
||||
@@ -19,17 +23,59 @@ export const SidebarButton = ({
|
||||
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} />}
|
||||
{children && children}
|
||||
<span className="flex flex-1 justify-start shrink-0">{label}</span>
|
||||
{count && count > 0 && !active && <div className="justify-end text-white rounded-[20%] px-[2%] bg-[rgb(195,0,0)]">{count}</div>}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Types } from "@meshtastic/js";
|
||||
import { Message, MessageType, MessageState } from "@core/stores/messageStore.ts";
|
||||
import { Message, MessageType, MessageState } from "../stores/messageStore/index.ts";
|
||||
|
||||
class PacketToMessageDTO {
|
||||
channel: Types.ChannelNumber;
|
||||
|
||||
+115
-255
@@ -6,31 +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" | 'failed';
|
||||
|
||||
export interface ProcessPacketParams {
|
||||
from: number;
|
||||
snr: number;
|
||||
time: number;
|
||||
}
|
||||
|
||||
export type DialogVariant =
|
||||
| "import"
|
||||
| "QR"
|
||||
| "shutdown"
|
||||
| "reboot"
|
||||
| "rebootOTA"
|
||||
| "deviceName"
|
||||
| "nodeRemoval"
|
||||
| "pkiBackup"
|
||||
| "nodeDetails"
|
||||
| "unsafeRoles"
|
||||
| "refreshKeys"
|
||||
| "deleteMessages";
|
||||
export type DialogVariant = keyof Device["dialog"];
|
||||
|
||||
type NodeError = {
|
||||
node: number;
|
||||
@@ -46,7 +28,6 @@ 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>;
|
||||
traceroutes: Map<
|
||||
number,
|
||||
@@ -57,10 +38,10 @@ export interface Device {
|
||||
activePage: Page;
|
||||
activeNode: number;
|
||||
waypoints: Protobuf.Mesh.Waypoint[];
|
||||
// currentMetrics: Protobuf.DeviceMetrics;
|
||||
pendingSettingsChanges: boolean;
|
||||
messageDraft: string;
|
||||
unreadCounts: Map<number, number>;
|
||||
nodesMap: Map<number, Protobuf.Mesh.NodeInfo>; // dont access directly, use getNodes, or getNode
|
||||
dialog: {
|
||||
import: boolean;
|
||||
QR: boolean;
|
||||
@@ -76,14 +57,12 @@ export interface Device {
|
||||
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;
|
||||
@@ -105,9 +84,15 @@ export interface Device {
|
||||
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 {
|
||||
@@ -125,6 +110,7 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
||||
remoteDevices: new Map(),
|
||||
|
||||
addDevice: (id: number) => {
|
||||
|
||||
set(
|
||||
produce<DeviceState>((draft) => {
|
||||
draft.devices.set(id, {
|
||||
@@ -136,7 +122,6 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
||||
workingConfig: [],
|
||||
workingModuleConfig: [],
|
||||
hardware: create(Protobuf.Mesh.MyNodeInfoSchema),
|
||||
nodes: new Map(),
|
||||
metadata: new Map(),
|
||||
traceroutes: new Map(),
|
||||
connection: undefined,
|
||||
@@ -161,7 +146,7 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
||||
messageDraft: "",
|
||||
nodeErrors: new Map(),
|
||||
unreadCounts: new Map(),
|
||||
|
||||
nodesMap: new Map(),
|
||||
|
||||
setStatus: (status: Types.DeviceStatusEnum) => {
|
||||
set(
|
||||
@@ -177,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; }
|
||||
}
|
||||
}
|
||||
}),
|
||||
@@ -220,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; }
|
||||
}
|
||||
}
|
||||
}),
|
||||
@@ -289,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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -337,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) => {
|
||||
@@ -405,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);
|
||||
}),
|
||||
);
|
||||
},
|
||||
@@ -417,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);
|
||||
}
|
||||
@@ -431,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);
|
||||
}),
|
||||
);
|
||||
},
|
||||
@@ -458,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) => {
|
||||
@@ -472,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) => {
|
||||
@@ -493,10 +351,9 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
||||
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);
|
||||
}),
|
||||
);
|
||||
},
|
||||
@@ -504,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);
|
||||
}),
|
||||
);
|
||||
},
|
||||
@@ -523,46 +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);
|
||||
}),
|
||||
);
|
||||
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,
|
||||
@@ -606,26 +448,19 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
||||
},
|
||||
getNodeError: (nodeNum: number) => {
|
||||
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.nodeErrors.get(nodeNum);
|
||||
},
|
||||
hasNodeError: (nodeNum: number) => {
|
||||
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.nodeErrors.has(nodeNum);
|
||||
},
|
||||
incrementUnread: (nodeNum: number) => {
|
||||
set(
|
||||
produce<DeviceState>((draft) => {
|
||||
const device = draft.devices.get(id);
|
||||
if (!device) {
|
||||
console.warn(`incrementUnread: Device with ID ${id} not found.`);
|
||||
return;
|
||||
}
|
||||
if (!device) return;
|
||||
const currentCount = device.unreadCounts.get(nodeNum) ?? 0;
|
||||
device.unreadCounts.set(nodeNum, currentCount + 1);
|
||||
})
|
||||
@@ -635,29 +470,54 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
||||
set(
|
||||
produce<DeviceState>((draft) => {
|
||||
const device = draft.devices.get(id);
|
||||
if (!device) {
|
||||
console.warn(`resetUnread: Device with ID ${id} not found.`);
|
||||
return;
|
||||
}
|
||||
if (!device) return;
|
||||
device.unreadCounts.set(nodeNum, 0);
|
||||
|
||||
if (device.unreadCounts.get(nodeNum) === 0) {
|
||||
device.unreadCounts.delete(nodeNum);
|
||||
}
|
||||
})
|
||||
);
|
||||
},
|
||||
getNodes: (filter?: (node: Protobuf.Mesh.NodeInfo) => boolean): Protobuf.Mesh.NodeInfo[] => {
|
||||
const device = get().devices.get(id);
|
||||
if (!device) {
|
||||
return [];
|
||||
}
|
||||
const allNodes = Array.from(device.nodesMap.values()).filter(
|
||||
(node) => node.num !== get().devices.get(id)?.hardware.myNodeNum);
|
||||
if (filter) {
|
||||
return allNodes.filter(filter);
|
||||
}
|
||||
return allNodes;
|
||||
},
|
||||
getNode: (nodeNum: number): Protobuf.Mesh.NodeInfo | undefined => {
|
||||
const device = get().devices.get(id);
|
||||
if (!device) {
|
||||
return;
|
||||
}
|
||||
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) => {
|
||||
|
||||
@@ -1,371 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import {
|
||||
useMessageStore,
|
||||
MessageType,
|
||||
MessageState,
|
||||
type Message,
|
||||
} from './messageStore.ts';
|
||||
|
||||
let memoryStorage: Record<string, string> = {};
|
||||
|
||||
vi.mock('./storage/indexDB.ts', () => {
|
||||
return {
|
||||
zustandIndexDBStorage: {
|
||||
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 = 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, true);
|
||||
});
|
||||
|
||||
it('should have correct initial state', () => {
|
||||
const state = useMessageStore.getState();
|
||||
expect(state.messages.direct).toEqual({});
|
||||
expect(state.messages.broadcast).toEqual({});
|
||||
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 structure', () => {
|
||||
useMessageStore.getState().saveMessage(directMessageToOther1);
|
||||
const state = useMessageStore.getState();
|
||||
expect(state.messages.direct[myNodeNum]).toBeDefined();
|
||||
expect(state.messages.direct[myNodeNum][otherNodeNum1]).toBeDefined();
|
||||
expect(
|
||||
state.messages.direct[myNodeNum][otherNodeNum1][directMessageToOther1.messageId],
|
||||
).toEqual(directMessageToOther1);
|
||||
});
|
||||
|
||||
it('should save a broadcast message with correct structure', () => {
|
||||
useMessageStore.getState().saveMessage(broadcastMessage1);
|
||||
const state = useMessageStore.getState();
|
||||
expect(state.messages.broadcast[broadcastChannel]).toBeDefined();
|
||||
expect(
|
||||
state.messages.broadcast[broadcastChannel][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();
|
||||
|
||||
// Direct msg 1 (me -> other1)
|
||||
expect(state.messages.direct[myNodeNum]?.[otherNodeNum1]?.[directMessageToOther1.messageId]).toEqual(directMessageToOther1);
|
||||
// Direct msg 2 (other1 -> me)
|
||||
expect(state.messages.direct[otherNodeNum1]?.[myNodeNum]?.[directMessageFromOther1.messageId]).toEqual(directMessageFromOther1);
|
||||
// Broadcast msg 1
|
||||
expect(state.messages.broadcast[broadcastChannel]?.[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(MessageType.Broadcast, {
|
||||
myNodeNum: myNodeNum, // Not strictly needed for broadcast, but good practice
|
||||
channel: 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(MessageType.Broadcast, {
|
||||
myNodeNum: myNodeNum,
|
||||
channel: 99
|
||||
});
|
||||
expect(messages).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return combined direct messages for a specific chat (pair), sorted by date', () => {
|
||||
const messages = useMessageStore.getState().getMessages(MessageType.Direct, {
|
||||
myNodeNum: myNodeNum,
|
||||
otherNodeNum: 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(MessageType.Direct, {
|
||||
myNodeNum: myNodeNum,
|
||||
otherNodeNum: 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(MessageType.Direct, {
|
||||
myNodeNum: myNodeNum,
|
||||
otherNodeNum: 999
|
||||
});
|
||||
expect(messages).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return combined direct messages when myNodeNum and otherNodeNum are provided', () => {
|
||||
const messages = useMessageStore.getState().getMessages(MessageType.Direct, {
|
||||
myNodeNum: myNodeNum, // Keep this
|
||||
otherNodeNum: otherNodeNum1
|
||||
});
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages[0]).toEqual(directMessageToOther1);
|
||||
expect(messages[1]).toEqual(directMessageFromOther1);
|
||||
});
|
||||
});
|
||||
|
||||
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 sent BY ME', () => {
|
||||
useMessageStore.getState().setMessageState({
|
||||
type: MessageType.Direct,
|
||||
key: otherNodeNum1,
|
||||
messageId: directMessageToOther1.messageId,
|
||||
newState: MessageState.Ack,
|
||||
});
|
||||
const message = useMessageStore.getState().messages.direct[myNodeNum]?.[otherNodeNum1]?.[directMessageToOther1.messageId];
|
||||
expect(message?.state).toBe(MessageState.Ack);
|
||||
});
|
||||
|
||||
it('should update state for a direct message received FROM OTHER', () => {
|
||||
useMessageStore.getState().setMessageState({
|
||||
type: MessageType.Direct,
|
||||
key: otherNodeNum1,
|
||||
messageId: directMessageFromOther1.messageId,
|
||||
newState: MessageState.Failed,
|
||||
});
|
||||
const message = useMessageStore.getState().messages.direct[otherNodeNum1]?.[myNodeNum]?.[directMessageFromOther1.messageId];
|
||||
expect(message?.state).toBe(MessageState.Failed);
|
||||
});
|
||||
|
||||
it('should update state for a broadcast message', () => {
|
||||
useMessageStore.getState().setMessageState({
|
||||
type: MessageType.Broadcast,
|
||||
key: broadcastChannel,
|
||||
messageId: broadcastMessage1.messageId,
|
||||
newState: MessageState.Ack,
|
||||
});
|
||||
const message = useMessageStore.getState().messages.broadcast[broadcastChannel]?.[broadcastMessage1.messageId];
|
||||
expect(message?.state).toBe(MessageState.Ack);
|
||||
});
|
||||
|
||||
it('should warn if message is not found', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
|
||||
useMessageStore.getState().setMessageState({
|
||||
type: MessageType.Direct,
|
||||
key: otherNodeNum1,
|
||||
messageId: 999,
|
||||
newState: MessageState.Ack,
|
||||
});
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Message not found for state update'));
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('clearMessageByMessageId', () => {
|
||||
beforeEach(() => {
|
||||
useMessageStore.getState().setNodeNum(myNodeNum);
|
||||
useMessageStore.getState().saveMessage(directMessageToOther1);
|
||||
useMessageStore.getState().saveMessage(directMessageFromOther1);
|
||||
useMessageStore.getState().saveMessage(broadcastMessage1);
|
||||
useMessageStore.getState().saveMessage({ ...directMessageToOther1, messageId: 1011, date: Date.now() + 50 });
|
||||
});
|
||||
|
||||
it('should delete a specific direct message (sent by me)', () => {
|
||||
const messageIdToDelete = directMessageToOther1.messageId;
|
||||
useMessageStore.getState().clearMessageByMessageId({
|
||||
type: MessageType.Direct,
|
||||
from: myNodeNum,
|
||||
to: otherNodeNum1,
|
||||
messageId: messageIdToDelete
|
||||
});
|
||||
const state = useMessageStore.getState();
|
||||
expect(state.messages.direct[myNodeNum]?.[otherNodeNum1]?.[messageIdToDelete]).toBeUndefined();
|
||||
expect(state.messages.direct[myNodeNum]?.[otherNodeNum1]?.[1011]).toBeDefined();
|
||||
expect(state.messages.direct[otherNodeNum1]?.[myNodeNum]?.[directMessageFromOther1.messageId]).toBeDefined();
|
||||
});
|
||||
|
||||
it('should delete a specific direct message (sent by other)', () => {
|
||||
const messageIdToDelete = directMessageFromOther1.messageId;
|
||||
useMessageStore.getState().clearMessageByMessageId({
|
||||
type: MessageType.Direct,
|
||||
from: otherNodeNum1,
|
||||
to: myNodeNum,
|
||||
messageId: messageIdToDelete
|
||||
});
|
||||
const state = useMessageStore.getState();
|
||||
expect(state.messages.direct[otherNodeNum1]?.[myNodeNum]?.[messageIdToDelete]).toBeUndefined();
|
||||
expect(state.messages.direct[myNodeNum]?.[otherNodeNum1]?.[directMessageToOther1.messageId]).toBeDefined();
|
||||
expect(state.messages.direct[myNodeNum]?.[otherNodeNum1]?.[1011]).toBeDefined();
|
||||
});
|
||||
|
||||
it('should delete a specific broadcast message', () => {
|
||||
const messageIdToDelete = broadcastMessage1.messageId;
|
||||
useMessageStore.getState().clearMessageByMessageId({
|
||||
type: MessageType.Broadcast,
|
||||
channel: broadcastChannel,
|
||||
messageId: messageIdToDelete
|
||||
});
|
||||
const state = useMessageStore.getState();
|
||||
expect(state.messages.broadcast[broadcastChannel]?.[messageIdToDelete]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should clean up empty to/from/channel objects', () => {
|
||||
useMessageStore.getState().clearMessageByMessageId({ type: MessageType.Direct, from: otherNodeNum1, to: myNodeNum, messageId: directMessageFromOther1.messageId });
|
||||
expect(useMessageStore.getState().messages.direct[otherNodeNum1]?.[myNodeNum]).toBeUndefined(); // Recipient level removed
|
||||
expect(useMessageStore.getState().messages.direct[otherNodeNum1]).toBeUndefined(); // Sender level removed
|
||||
|
||||
useMessageStore.getState().clearMessageByMessageId({ type: MessageType.Broadcast, channel: broadcastChannel, messageId: broadcastMessage1.messageId });
|
||||
expect(useMessageStore.getState().messages.broadcast[broadcastChannel]).toBeUndefined(); // Channel level removed
|
||||
});
|
||||
});
|
||||
|
||||
describe('Drafts', () => {
|
||||
const draftKey = otherNodeNum1;
|
||||
const draftMessage = 'This is a draft';
|
||||
|
||||
it('should set and get a draft', () => {
|
||||
useMessageStore.getState().setDraft(draftKey, draftMessage);
|
||||
expect(useMessageStore.getState().draft.get(draftKey)).toBe(draftMessage);
|
||||
expect(useMessageStore.getState().getDraft(draftKey)).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(draftKey, draftMessage);
|
||||
expect(useMessageStore.getState().draft.has(draftKey)).toBe(true);
|
||||
useMessageStore.getState().clearDraft(draftKey);
|
||||
expect(useMessageStore.getState().draft.has(draftKey)).toBe(false);
|
||||
expect(useMessageStore.getState().getDraft(draftKey)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteAllMessages', () => {
|
||||
it('should clear all direct and broadcast messages', () => {
|
||||
useMessageStore.getState().saveMessage(directMessageToOther1);
|
||||
useMessageStore.getState().saveMessage(broadcastMessage1);
|
||||
expect(Object.keys(useMessageStore.getState().messages.direct).length).toBeGreaterThan(0);
|
||||
expect(Object.keys(useMessageStore.getState().messages.broadcast).length).toBeGreaterThan(0);
|
||||
|
||||
useMessageStore.getState().deleteAllMessages();
|
||||
|
||||
expect(useMessageStore.getState().messages.direct).toEqual({});
|
||||
expect(useMessageStore.getState().messages.broadcast).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
@@ -1,234 +0,0 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { produce } from 'immer';
|
||||
import { Types } from '@meshtastic/core';
|
||||
import { zustandIndexDBStorage } from "./storage/indexDB.ts";
|
||||
|
||||
export enum MessageState {
|
||||
Ack = "ack",
|
||||
Waiting = "waiting",
|
||||
Failed = "failed",
|
||||
}
|
||||
|
||||
export enum MessageType {
|
||||
Direct = "direct",
|
||||
Broadcast = "broadcast",
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export type Message = GenericMessage<MessageType.Direct> | GenericMessage<MessageType.Broadcast>;
|
||||
|
||||
export interface MessageStore {
|
||||
messages: {
|
||||
direct: Record<number, Record<number, Record<number, Message>>>;
|
||||
broadcast: Record<number, Record<number, Message>>; // channel -> messageId -> Message
|
||||
};
|
||||
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;
|
||||
getNodeNum: () => number;
|
||||
setActiveChat: (chat: number) => void;
|
||||
setChatType: (type: MessageType) => void;
|
||||
saveMessage: (message: Message) => void;
|
||||
setMessageState: (params: {
|
||||
type: MessageType;
|
||||
// For Direct: Represents the *other* node number involved in the chat.
|
||||
// For Broadcast: Represents the channel number.
|
||||
key: number;
|
||||
messageId: number;
|
||||
newState?: MessageState;
|
||||
}) => void;
|
||||
getMessages: (type: MessageType, options: { myNodeNum: number; otherNodeNum?: number; channel?: number }) => Message[];
|
||||
getDraft: (key: Types.Destination) => string;
|
||||
setDraft: (key: Types.Destination, message: string) => void;
|
||||
deleteAllMessages: () => void;
|
||||
clearMessageByMessageId: (params: {
|
||||
type: MessageType;
|
||||
from?: number;
|
||||
to?: number;
|
||||
channel?: number;
|
||||
messageId: number
|
||||
}) => void;
|
||||
clearDraft: (key: Types.Destination) => void;
|
||||
}
|
||||
|
||||
const CURRENT_STORE_VERSION = 0;
|
||||
|
||||
export const useMessageStore = create<MessageStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
messages: {
|
||||
direct: {}, // Record<sender, Record<recipient, Record<messageId, Message>>>
|
||||
broadcast: {},
|
||||
},
|
||||
draft: new Map<number, string>(),
|
||||
activeChat: 0,
|
||||
chatType: MessageType.Broadcast,
|
||||
nodeNum: 0,
|
||||
setNodeNum: (nodeNum) => {
|
||||
set(produce((state: MessageStore) => {
|
||||
state.nodeNum = nodeNum;
|
||||
}));
|
||||
},
|
||||
getNodeNum: () => get().nodeNum,
|
||||
setActiveChat: (chat) => {
|
||||
set(produce((state: MessageStore) => {
|
||||
state.activeChat = chat;
|
||||
}));
|
||||
},
|
||||
setChatType: (type) => {
|
||||
set(produce((state: MessageStore) => {
|
||||
state.chatType = type;
|
||||
}));
|
||||
},
|
||||
saveMessage: (message) => {
|
||||
set(produce((state: MessageStore) => {
|
||||
if (message.type === MessageType.Direct) {
|
||||
const sender = Number(message.from);
|
||||
const recipient = Number(message.to);
|
||||
|
||||
if (!state.messages.direct[sender]) {
|
||||
state.messages.direct[sender] = {};
|
||||
}
|
||||
if (!state.messages.direct[sender][recipient]) {
|
||||
state.messages.direct[sender][recipient] = {};
|
||||
}
|
||||
state.messages.direct[sender][recipient][message.messageId] = message;
|
||||
|
||||
} else if (message.type === MessageType.Broadcast) {
|
||||
const channel = Number(message.channel);
|
||||
if (!state.messages.broadcast[channel]) {
|
||||
state.messages.broadcast[channel] = {};
|
||||
}
|
||||
state.messages.broadcast[channel][message.messageId] = message;
|
||||
}
|
||||
}));
|
||||
},
|
||||
setMessageState: ({
|
||||
type,
|
||||
key,
|
||||
messageId,
|
||||
newState = MessageState.Ack,
|
||||
}) => {
|
||||
set(
|
||||
produce((state: MessageStore) => {
|
||||
let message: Message | undefined;
|
||||
|
||||
if (type === MessageType.Broadcast) {
|
||||
const channel = key;
|
||||
message = state.messages.broadcast?.[channel]?.[messageId];
|
||||
} else if (type === MessageType.Direct) {
|
||||
const otherNodeNum = key;
|
||||
const myNodeNum = state.nodeNum;
|
||||
|
||||
message = state.messages.direct?.[myNodeNum]?.[otherNodeNum]?.[messageId];
|
||||
|
||||
if (!message) {
|
||||
message = state.messages.direct?.[otherNodeNum]?.[myNodeNum]?.[messageId];
|
||||
}
|
||||
}
|
||||
|
||||
if (message) {
|
||||
message.state = newState;
|
||||
} else {
|
||||
console.warn(`Message not found for state update - type: ${type}, key (otherNode/channel): ${key}, messageId: ${messageId}, myNodeNum: ${state.nodeNum}`);
|
||||
}
|
||||
}),
|
||||
);
|
||||
},
|
||||
getMessages: (type, options) => {
|
||||
const state = get();
|
||||
|
||||
if (type === MessageType.Broadcast && options.channel !== undefined) {
|
||||
const messageMap = state.messages.broadcast[options.channel] ?? {};
|
||||
return Object.values(messageMap).sort((a, b) => a.date - b.date);
|
||||
}
|
||||
|
||||
if (type === MessageType.Direct && options.myNodeNum !== undefined && options.otherNodeNum !== undefined) {
|
||||
const myNodeNum = options.myNodeNum;
|
||||
const otherNodeNum = options.otherNodeNum;
|
||||
|
||||
// Messages sent BY ME TO OTHER
|
||||
const sentByMeMap = state.messages.direct?.[myNodeNum]?.[otherNodeNum] ?? {};
|
||||
const sentByMe = Object.values(sentByMeMap);
|
||||
|
||||
// Messages sent BY OTHER TO ME
|
||||
const sentByOtherMap = state.messages.direct?.[otherNodeNum]?.[myNodeNum] ?? {};
|
||||
const sentByOther = Object.values(sentByOtherMap);
|
||||
|
||||
// Merge and sort chronologically
|
||||
return [...sentByMe, ...sentByOther].sort((a, b) => a.date - b.date);
|
||||
}
|
||||
return [];
|
||||
},
|
||||
clearMessageByMessageId: ({ type, from, to, channel, messageId }) => {
|
||||
set(produce((state: MessageStore) => {
|
||||
if (type === MessageType.Broadcast && channel !== undefined) {
|
||||
const messageMap = state.messages.broadcast[channel];
|
||||
if (messageMap?.[messageId]) {
|
||||
delete messageMap[messageId];
|
||||
if (Object.keys(messageMap).length === 0) {
|
||||
delete state.messages.broadcast[channel];
|
||||
}
|
||||
}
|
||||
} else if (type === MessageType.Direct && from !== undefined && to !== undefined) {
|
||||
const messageMap = state.messages.direct?.[from]?.[to];
|
||||
if (messageMap?.[messageId]) {
|
||||
delete messageMap[messageId];
|
||||
if (Object.keys(messageMap).length === 0) {
|
||||
delete state.messages.direct[from][to];
|
||||
if (Object.keys(state.messages.direct[from]).length === 0) {
|
||||
delete state.messages.direct[from];
|
||||
}
|
||||
}
|
||||
}
|
||||
console.warn("clearMessageByMessageId called without sufficient identifiers for type", type);
|
||||
}
|
||||
}));
|
||||
},
|
||||
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 = {};
|
||||
state.messages.broadcast = {};
|
||||
}));
|
||||
}
|
||||
}),
|
||||
{
|
||||
name: 'meshtastic-message-store',
|
||||
storage: createJSONStorage(() => zustandIndexDBStorage),
|
||||
version: CURRENT_STORE_VERSION,
|
||||
partialize: (state) => ({
|
||||
messages: state.messages,
|
||||
nodeNum: state.nodeNum,
|
||||
}),
|
||||
}
|
||||
));
|
||||
@@ -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,
|
||||
// }),
|
||||
// })
|
||||
)
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -1,5 +1,14 @@
|
||||
import { StateStorage } from "zustand/middleware";
|
||||
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> => {
|
||||
@@ -11,4 +20,62 @@ export const zustandIndexDBStorage: StateStorage = {
|
||||
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);
|
||||
},
|
||||
};
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { Device } from "@core/stores/deviceStore.ts";
|
||||
import { MeshDevice, Protobuf } from "@meshtastic/core";
|
||||
import { MessageType, type MessageStore } from "@core/stores/messageStore.ts";
|
||||
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,
|
||||
@@ -10,9 +11,6 @@ export const subscribeAll = (
|
||||
) => {
|
||||
let myNodeNum = 0;
|
||||
|
||||
// onLogEvent
|
||||
// onMeshHeartbeat
|
||||
|
||||
connection.events.onDeviceMetadataPacket.subscribe((metadataPacket) => {
|
||||
device.addMetadata(metadataPacket.from, metadataPacket.data);
|
||||
});
|
||||
@@ -59,18 +57,20 @@ export const subscribeAll = (
|
||||
});
|
||||
|
||||
connection.events.onUserPacket.subscribe((user) => {
|
||||
console.log("User Packet", user);
|
||||
|
||||
device.addUser(user);
|
||||
});
|
||||
|
||||
connection.events.onPositionPacket.subscribe((position) => {
|
||||
console.log("Position Packet", position);
|
||||
|
||||
device.addPosition(position);
|
||||
});
|
||||
|
||||
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) => {
|
||||
@@ -86,6 +86,8 @@ export const subscribeAll = (
|
||||
|
||||
connection.events.onMessagePacket.subscribe((messagePacket) => {
|
||||
// incoming and outgoing messages are handled by this event listener
|
||||
console.log("Message Packet", messagePacket);
|
||||
|
||||
const dto = new PacketToMessageDTO(messagePacket, myNodeNum);
|
||||
const message = dto.toMessage();
|
||||
messageStore.saveMessage(message);
|
||||
@@ -123,6 +125,9 @@ export const subscribeAll = (
|
||||
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]);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"] {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
|
||||
@@ -35,7 +35,7 @@ const convertToLatLng = (position: {
|
||||
});
|
||||
|
||||
const MapPage = () => {
|
||||
const { nodes, waypoints } = useDevice();
|
||||
const { getNodes, waypoints } = useDevice();
|
||||
const { theme } = useTheme();
|
||||
const { default: map } = useMap();
|
||||
|
||||
@@ -48,11 +48,11 @@ 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 {
|
||||
@@ -161,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}
|
||||
@@ -213,6 +212,7 @@ const MapPage = () => {
|
||||
longitude={convertToLatLng(selectedNode.position).longitude}
|
||||
latitude={convertToLatLng(selectedNode.position).latitude}
|
||||
onClose={() => setSelectedNode(null)}
|
||||
className="w-full"
|
||||
>
|
||||
<NodeDetail node={selectedNode} />
|
||||
</Popup>
|
||||
|
||||
+188
-144
@@ -3,179 +3,223 @@ 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 { MessageType, useMessageStore } from "@core/stores/messageStore.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, hasNodeError, unreadCounts, resetUnread } = useDevice();
|
||||
const { getNodeNum, getMessages, setActiveChat, chatType, activeChat, setChatType } = useMessageStore()
|
||||
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 deferredSearch = useDeferredValue(searchTerm);
|
||||
|
||||
const filteredNodes: NodeInfoWithUnread[] = Array.from(nodes.values())
|
||||
.filter((node) => node.num !== hardware.myNodeNum)
|
||||
.map((node) => ({
|
||||
...node,
|
||||
unreadCount: unreadCounts.get(node.num) ?? 0,
|
||||
}))
|
||||
.filter((node) => {
|
||||
const nodeName = node.user?.longName ?? `!${numberToHexUnpadded(node.num)}`;
|
||||
return nodeName.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
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)
|
||||
})
|
||||
.sort((a, b) => b.unreadCount - a.unreadCount);
|
||||
.map((node) => ({
|
||||
...node,
|
||||
unreadCount: unreadCounts.get(node.num) ?? 0,
|
||||
}))
|
||||
.sort((a, b) => b.unreadCount - a.unreadCount);
|
||||
}
|
||||
|
||||
console.log('filtered nodes', filteredNodes());
|
||||
|
||||
const allChannels = Array.from(channels.values());
|
||||
const filteredChannels = allChannels.filter(
|
||||
(ch) => ch.role !== Protobuf.Channel.Channel_Role.DISABLED,
|
||||
);
|
||||
const currentChannel = channels.get(activeChat);
|
||||
|
||||
const otherNode = nodes.get(activeChat);
|
||||
|
||||
const nodeHex = otherNode?.num ? numberToHexUnpadded(otherNode.num) : "Unknown";
|
||||
const otherNode = getNode(activeChat);
|
||||
|
||||
const isDirect = chatType === MessageType.Direct;
|
||||
const isBroadcast = chatType === MessageType.Broadcast;
|
||||
|
||||
const currentChat = { type: chatType, id: activeChat };
|
||||
const sendText = useCallback(async (message: string) => {
|
||||
const isDirect = chatType === MessageType.Direct;
|
||||
const toValue = isDirect ? activeChat : MessageType.Broadcast;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sidebar>
|
||||
<SidebarSection label="Channels">
|
||||
{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="mr-2" />
|
||||
</SidebarButton>
|
||||
))}
|
||||
</SidebarSection>
|
||||
<SidebarSection label="Nodes">
|
||||
<div className="p-1 mb-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 dark:bg-slate-700 dark:border-slate-600 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
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);
|
||||
console.log("Message sent, ID:", messageId);
|
||||
|
||||
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]);
|
||||
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3.5">
|
||||
{filteredNodes.map((node) => (
|
||||
<SidebarButton
|
||||
key={node.num}
|
||||
label={node.user?.longName ?? `!${numberToHexUnpadded(node.num)}`}
|
||||
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 ?? node.num.toString()}
|
||||
className={cn(hasNodeError(node.num) && "text-red-500")}
|
||||
showError={hasNodeError(node.num)}
|
||||
size="sm"
|
||||
/>
|
||||
</SidebarButton>
|
||||
))}
|
||||
</div>
|
||||
</SidebarSection>
|
||||
</Sidebar>
|
||||
<div className="flex flex-col w-full h-full">
|
||||
<PageLayout
|
||||
className="flex flex-col h-full"
|
||||
label={`Messages: ${isBroadcast && currentChannel
|
||||
? getChannelName(currentChannel)
|
||||
: isDirect && otherNode
|
||||
? (otherNode.user?.longName ?? nodeHex)
|
||||
: "Select a Chat"
|
||||
}`}
|
||||
actions={isDirect && otherNode
|
||||
? [
|
||||
{
|
||||
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-1 overflow-y-auto">
|
||||
{isBroadcast && currentChannel && (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<ChannelChat
|
||||
messages={getMessages(MessageType.Broadcast, {
|
||||
myNodeNum: getNodeNum(),
|
||||
channel: currentChannel?.index
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isDirect && otherNode && (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<ChannelChat
|
||||
messages={getMessages(MessageType.Direct, { myNodeNum: getNodeNum(), otherNodeNum: activeChat })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isBroadcast && !isDirect && (
|
||||
<div className="flex items-center justify-center h-full text-slate-500">
|
||||
Select a channel or node to start messaging.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 p-4 w-full dark:bg-slate-900">
|
||||
{(isBroadcast || isDirect) ? (
|
||||
<MessageInput
|
||||
to={isDirect ? activeChat : MessageType.Broadcast}
|
||||
channel={isDirect ? Types.ChannelNumber.Primary : currentChat.id}
|
||||
maxBytes={200}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center text-slate-400 italic">Select a chat to send a message.</div>
|
||||
)}
|
||||
</div>
|
||||
</PageLayout>
|
||||
</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={isDirect ? activeChat : MessageType.Broadcast}
|
||||
onSend={sendText}
|
||||
maxBytes={200}
|
||||
/>
|
||||
) : (
|
||||
<div className="p-4 text-center text-slate-400 italic">Select a chat to send a message.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+25
-41
@@ -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,29 +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);
|
||||
@@ -44,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;
|
||||
@@ -84,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 dark:bg-slate-700 dark:border-slate-600 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="overflow-y-auto h-full">
|
||||
<div className="overflow-y-auto">
|
||||
<Table
|
||||
headings={[
|
||||
{ title: "", type: "blank", sortable: false },
|
||||
@@ -109,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"
|
||||
@@ -117,23 +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" : ""}
|
||||
@@ -180,8 +165,7 @@ const NodesPage = (): JSX.Element => {
|
||||
onOpenChange={() => setSelectedLocation(undefined)}
|
||||
/>
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
</PageLayout>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user