Merge branch 'master' into unread-counts
This commit is contained in:
@@ -6,8 +6,9 @@ import { PkiBackupDialog } from "@components/Dialog/PKIBackupDialog.tsx";
|
||||
import { QRDialog } from "@components/Dialog/QRDialog.tsx";
|
||||
import { RebootDialog } from "@components/Dialog/RebootDialog.tsx";
|
||||
import { ShutdownDialog } from "@components/Dialog/ShutdownDialog.tsx";
|
||||
import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog.tsx";
|
||||
import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx";
|
||||
import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx";
|
||||
import { RefreshKeysDialog } from "@components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx";
|
||||
|
||||
export const DialogManager = () => {
|
||||
const { channels, config, dialog, setDialogOpen } = useDevice();
|
||||
@@ -70,6 +71,12 @@ export const DialogManager = () => {
|
||||
setDialogOpen("unsafeRoles", open);
|
||||
}}
|
||||
/>
|
||||
<RefreshKeysDialog
|
||||
open={dialog.refreshKeys}
|
||||
onOpenChange={(open) => {
|
||||
setDialogOpen("refreshKeys", open);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -53,7 +53,7 @@ const links: { [key: string]: string } = {
|
||||
|
||||
const listFormatter = new Intl.ListFormat("en", {
|
||||
style: "long",
|
||||
type: "conjunction",
|
||||
type: "disjunction",
|
||||
});
|
||||
|
||||
const ErrorMessage = ({ missingFeatures }: FeatureErrorProps) => {
|
||||
@@ -79,16 +79,16 @@ const ErrorMessage = ({ missingFeatures }: FeatureErrorProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Subtle className="flex flex-col items-start gap-2 text-slate-900 bg-red-200/80 p-4 rounded-md">
|
||||
<Subtle className="flex flex-col items-start gap-2 bg-red-500 p-4 rounded-md">
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<AlertCircle size={40} className="mr-2 shrink-0" />
|
||||
<AlertCircle size={40} className="mr-2 shrink-0 text-white" />
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-sm">
|
||||
<p className="text-sm text-white">
|
||||
{browserFeatures.length > 0 && (
|
||||
<>
|
||||
This application requires{" "}
|
||||
This connection type requires{" "}
|
||||
{formatFeatureList(browserFeatures)}. Please use a
|
||||
Chromium-based browser like Chrome or Edge.
|
||||
supported browser, like Chrome or Edge.
|
||||
</>
|
||||
)}
|
||||
{needsSecureContext && (
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
import { useAppStore } from "../../core/stores/appStore.ts";
|
||||
import { useDevice } from "../../core/stores/deviceStore.ts";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "../UI/Accordion.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../UI/Dialog.tsx";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||
import { DeviceImage } from "../generic/DeviceImage.tsx";
|
||||
import { TimeAgo } from "../generic/TimeAgo.tsx";
|
||||
import { Uptime } from "../generic/Uptime.tsx";
|
||||
|
||||
export interface NodeDetailsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const NodeDetailsDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: NodeDetailsDialogProps) => {
|
||||
const { nodes } = useDevice();
|
||||
const { nodeNumDetails } = useAppStore();
|
||||
const device: Protobuf.Mesh.NodeInfo = nodes.get(nodeNumDetails);
|
||||
|
||||
return device
|
||||
? (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Node Details for {device.user?.longName ?? "UNKNOWN"} (
|
||||
{device.user?.shortName ?? "UNK"})
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<div className="w-full">
|
||||
<DeviceImage
|
||||
className="w-32 h-32 mx-auto rounded-lg border-4 border-slate-200 dark:border-slate-800"
|
||||
deviceType={Protobuf.Mesh
|
||||
.HardwareModel[device.user?.hwModel ?? 0]}
|
||||
/>
|
||||
<div className="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">
|
||||
Details:
|
||||
</p>
|
||||
<p>
|
||||
Hardware:{" "}
|
||||
{Protobuf.Mesh.HardwareModel[device.user?.hwModel ?? 0]}
|
||||
</p>
|
||||
<p>Node Number: {device.num}</p>
|
||||
<p>Node HEX: !{numberToHexUnpadded(device.num)}</p>
|
||||
<p>
|
||||
Role: {Protobuf.Config.Config_DeviceConfig_Role[
|
||||
device.user?.role ?? 0
|
||||
]}
|
||||
</p>
|
||||
<p>
|
||||
Last Heard: {device.lastHeard === 0
|
||||
? (
|
||||
"Never"
|
||||
)
|
||||
: <TimeAgo timestamp={device.lastHeard * 1000} />}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{device.position
|
||||
? (
|
||||
<div className="mt-5 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">
|
||||
Position:
|
||||
</p>
|
||||
{device.position.latitudeI && device.position.longitudeI
|
||||
? (
|
||||
<p>
|
||||
Coordinates:{" "}
|
||||
<a
|
||||
className="text-blue-500 dark:text-blue-400"
|
||||
href={`https://www.openstreetmap.org/?mlat=${device.position.latitudeI / 1e7
|
||||
}&mlon=${device.position.longitudeI / 1e7
|
||||
}&layers=N`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{device.position.latitudeI / 1e7},{" "}
|
||||
{device.position.longitudeI / 1e7}
|
||||
</a>
|
||||
</p>
|
||||
)
|
||||
: null}
|
||||
{device.position.altitude
|
||||
? <p>Altitude: {device.position.altitude}m</p>
|
||||
: null}
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
|
||||
{device.deviceMetrics
|
||||
? (
|
||||
<div className="mt-5 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">
|
||||
Device Metrics:
|
||||
</p>
|
||||
{device.deviceMetrics.airUtilTx
|
||||
? (
|
||||
<p>
|
||||
Air TX utilization:{" "}
|
||||
{device.deviceMetrics.airUtilTx.toFixed(2)}%
|
||||
</p>
|
||||
)
|
||||
: null}
|
||||
{device.deviceMetrics.channelUtilization
|
||||
? (
|
||||
<p>
|
||||
Channel utilization:{" "}
|
||||
{device.deviceMetrics.channelUtilization.toFixed(2)}%
|
||||
</p>
|
||||
)
|
||||
: null}
|
||||
{device.deviceMetrics.batteryLevel
|
||||
? (
|
||||
<p>
|
||||
Battery level:{" "}
|
||||
{device.deviceMetrics.batteryLevel.toFixed(2)}%
|
||||
</p>
|
||||
)
|
||||
: null}
|
||||
{device.deviceMetrics.voltage
|
||||
? (
|
||||
<p>
|
||||
Voltage: {device.deviceMetrics.voltage.toFixed(2)}V
|
||||
</p>
|
||||
)
|
||||
: null}
|
||||
{device.deviceMetrics.uptimeSeconds
|
||||
? (
|
||||
<p>
|
||||
Uptime:{" "}
|
||||
<Uptime
|
||||
seconds={device.deviceMetrics.uptimeSeconds}
|
||||
/>
|
||||
</p>
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
|
||||
{device
|
||||
? (
|
||||
<div className="mt-5 w-full max-w-[464px] bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
|
||||
<Accordion
|
||||
className="AccordionRoot"
|
||||
type="single"
|
||||
collapsible
|
||||
>
|
||||
<AccordionItem className="AccordionItem" value="item-1">
|
||||
<AccordionTrigger>
|
||||
<p className="text-lg font-semibold text-slate-900 dark:text-slate-50">
|
||||
All Raw Metrics:
|
||||
</p>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="overflow-x-scroll">
|
||||
<pre className="text-xs w-full">
|
||||
{JSON.stringify(device, null, 2)}
|
||||
</pre>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
: null;
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, it, vi, expect, beforeEach, Mock } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { useAppStore } from "@core/stores/appStore.ts";
|
||||
|
||||
vi.mock("@core/stores/deviceStore");
|
||||
vi.mock("@core/stores/appStore");
|
||||
|
||||
describe("NodeDetailsDialog", () => {
|
||||
const mockDevice = {
|
||||
num: 1234,
|
||||
user: {
|
||||
longName: "Test Node",
|
||||
shortName: "TN",
|
||||
hwModel: 1,
|
||||
role: 1,
|
||||
},
|
||||
lastHeard: 1697500000,
|
||||
position: {
|
||||
latitudeI: 450000000,
|
||||
longitudeI: -750000000,
|
||||
altitude: 200,
|
||||
},
|
||||
deviceMetrics: {
|
||||
airUtilTx: 50.123,
|
||||
channelUtilization: 75.456,
|
||||
batteryLevel: 88.789,
|
||||
voltage: 4.2,
|
||||
uptimeSeconds: 3600,
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset mocks before each test
|
||||
vi.resetAllMocks();
|
||||
|
||||
(useDevice as Mock).mockReturnValue({
|
||||
nodes: new Map([[1234, mockDevice]]),
|
||||
});
|
||||
|
||||
(useAppStore as unknown as Mock).mockReturnValue({
|
||||
nodeNumDetails: 1234,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders node details correctly", () => {
|
||||
render(<NodeDetailsDialog open={true} onOpenChange={() => { }} />);
|
||||
|
||||
expect(screen.getByText(/Node Details for Test Node/i)).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText("Node Number: 1234")).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();
|
||||
});
|
||||
|
||||
it("renders null if device is not found", () => {
|
||||
(useDevice as Mock).mockReturnValue({
|
||||
nodes: new Map(),
|
||||
});
|
||||
|
||||
render(<NodeDetailsDialog open={true} onOpenChange={() => { }} />);
|
||||
expect(screen.queryByText(/Node Details for/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import { useAppStore } from "@core/stores/appStore.ts";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@components/UI/Accordion.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@components/UI/Dialog.tsx";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||
import { DeviceImage } from "@components/generic/DeviceImage.tsx";
|
||||
import { TimeAgo } from "@components/generic/TimeAgo.tsx";
|
||||
import { Uptime } from "@components/generic/Uptime.tsx";
|
||||
|
||||
export interface NodeDetailsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const NodeDetailsDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: NodeDetailsDialogProps) => {
|
||||
const { nodes } = useDevice();
|
||||
const { nodeNumDetails } = useAppStore();
|
||||
|
||||
const device = nodes.get(nodeNumDetails);
|
||||
|
||||
if (!device) return null;
|
||||
|
||||
const deviceMetricsMap = [
|
||||
{
|
||||
key: "airUtilTx",
|
||||
label: "Air TX utilization",
|
||||
value: device.deviceMetrics?.airUtilTx,
|
||||
format: (val: number) => `${val.toFixed(2)}%`,
|
||||
},
|
||||
{
|
||||
key: "channelUtilization",
|
||||
label: "Channel utilization",
|
||||
value: device.deviceMetrics?.channelUtilization,
|
||||
format: (val: number) => `${val.toFixed(2)}%`,
|
||||
},
|
||||
{
|
||||
key: "batteryLevel",
|
||||
label: "Battery level",
|
||||
value: device.deviceMetrics?.batteryLevel,
|
||||
format: (val: number) => `${val.toFixed(2)}%`,
|
||||
},
|
||||
{
|
||||
key: "voltage",
|
||||
label: "Voltage",
|
||||
value: device.deviceMetrics?.voltage,
|
||||
format: (val: number) => `${val.toFixed(2)}V`,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent >
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Node Details for {device.user?.longName ?? "UNKNOWN"} (
|
||||
{device.user?.shortName ?? "UNK"})
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<div className="w-full">
|
||||
<div className="flex flex-col">
|
||||
<DeviceImage
|
||||
className="w-32 h-32 mx-auto rounded-lg border-4 border-slate-200 dark:border-slate-800"
|
||||
deviceType={
|
||||
Protobuf.Mesh.HardwareModel[device.user?.hwModel ?? 0]
|
||||
}
|
||||
/>
|
||||
<div className="bg-slate-100 text-slate-900 dark:text-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
|
||||
<p className="text-lg font-semibold">Details:</p>
|
||||
<p>
|
||||
Hardware:{" "}
|
||||
{Protobuf.Mesh.HardwareModel[device.user?.hwModel ?? 0]}
|
||||
</p>
|
||||
<p>Node Number: {device.num}</p>
|
||||
<p>Node Hex: !{numberToHexUnpadded(device.num)}</p>
|
||||
<p>
|
||||
Role:{" "}
|
||||
{
|
||||
Protobuf.Config.Config_DeviceConfig_Role[
|
||||
device.user?.role ?? 0
|
||||
]
|
||||
}
|
||||
</p>
|
||||
<p>
|
||||
Last Heard:{" "}
|
||||
{device.lastHeard === 0 ? "Never" : <TimeAgo timestamp={device.lastHeard * 1000} />}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{device.position && (
|
||||
<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">Position:</p>
|
||||
{device.position.latitudeI && device.position.longitudeI && (
|
||||
<p>
|
||||
Coordinates:{" "}
|
||||
<a
|
||||
className="text-blue-500 dark:text-blue-400"
|
||||
href={`https://www.openstreetmap.org/?mlat=${device.position.latitudeI / 1e7
|
||||
}&mlon=${device.position.longitudeI / 1e7
|
||||
}&layers=N`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{device.position.latitudeI / 1e7},{" "}
|
||||
{device.position.longitudeI / 1e7}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
{device.position.altitude && (
|
||||
<p>Altitude: {device.position.altitude}m</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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">
|
||||
Device Metrics:
|
||||
</p>
|
||||
{deviceMetricsMap.map(
|
||||
(metric) =>
|
||||
metric.value !== undefined && (
|
||||
<p key={metric.key}>
|
||||
{metric.label}: {metric.format(metric.value)}
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
{device.deviceMetrics.uptimeSeconds && (
|
||||
<p>
|
||||
Uptime:{" "}
|
||||
<Uptime seconds={device.deviceMetrics.uptimeSeconds} />
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
<div className="text-slate-900 dark:text-slate-100 w-full max-w-[464px] bg-slate-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
|
||||
<Accordion className="AccordionRoot" type="single" collapsible>
|
||||
<AccordionItem className="AccordionItem" value="item-1">
|
||||
<AccordionTrigger>
|
||||
<p className="text-lg font-semibold text-slate-900 dark:text-slate-50">
|
||||
All Raw Metrics:
|
||||
</p>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="overflow-x-scroll">
|
||||
<pre className="text-xs w-full">
|
||||
{JSON.stringify(device, null, 2)}
|
||||
</pre>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, Mock, vi } from "vitest";
|
||||
import { RefreshKeysDialog } from "./RefreshKeysDialog";
|
||||
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
|
||||
|
||||
vi.mock("./useRefreshKeysDialog.ts", () => ({
|
||||
useRefreshKeysDialog: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("RefreshKeysDialog Component", () => {
|
||||
let handleCloseDialogMock: Mock;
|
||||
let handleNodeRemoveMock: Mock;
|
||||
let onOpenChangeMock: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
handleCloseDialogMock = vi.fn();
|
||||
handleNodeRemoveMock = vi.fn();
|
||||
onOpenChangeMock = vi.fn();
|
||||
|
||||
(useRefreshKeysDialog as Mock).mockReturnValue({
|
||||
handleCloseDialog: handleCloseDialogMock,
|
||||
handleNodeRemove: handleNodeRemoveMock,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the dialog with correct content", () => {
|
||||
render(<RefreshKeysDialog open={true} onOpenChange={onOpenChangeMock} />);
|
||||
expect(screen.getByText("Keys Mismatch")).toBeInTheDocument();
|
||||
expect(screen.getByText("Request New Keys")).toBeInTheDocument();
|
||||
expect(screen.getByText("Dismiss")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls handleNodeRemove when 'Request New Keys' button is clicked", () => {
|
||||
render(<RefreshKeysDialog open={true} onOpenChange={onOpenChangeMock} />);
|
||||
fireEvent.click(screen.getByText("Request New Keys"));
|
||||
expect(handleNodeRemoveMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls handleCloseDialog when 'Dismiss' button is clicked", () => {
|
||||
render(<RefreshKeysDialog open={true} onOpenChange={onOpenChangeMock} />);
|
||||
fireEvent.click(screen.getByText("Dismiss"));
|
||||
expect(handleCloseDialogMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onOpenChange when dialog close button is clicked", () => {
|
||||
render(<RefreshKeysDialog open={true} onOpenChange={onOpenChangeMock} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /close/i }));
|
||||
expect(handleCloseDialogMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not render when open is false", () => {
|
||||
render(<RefreshKeysDialog open={false} onOpenChange={onOpenChangeMock} />);
|
||||
expect(screen.queryByText("Keys Mismatch")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@components/UI/Dialog.tsx";
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import { LockKeyholeOpenIcon } from "lucide-react";
|
||||
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
|
||||
|
||||
export interface RefreshKeysDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const RefreshKeysDialog = ({ open, onOpenChange }: RefreshKeysDialogProps) => {
|
||||
|
||||
const { handleCloseDialog, handleNodeRemove } = useRefreshKeysDialog();
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-8 flex flex-col gap-2">
|
||||
<DialogClose onClick={handleCloseDialog} />
|
||||
<DialogHeader>
|
||||
<DialogTitle>Keys Mismatch</DialogTitle>
|
||||
</DialogHeader>
|
||||
Your node is unable to send a direct message to this node. This is due to the remote node's current public key not matching the previously stored key for this node.
|
||||
<ul className="mt-2">
|
||||
<li className="flex place-items-center gap-2 items-start">
|
||||
<div className="p-2 bg-slate-500 rounded-lg mt-1">
|
||||
<LockKeyholeOpenIcon size={30} className="text-white justify-center" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div>
|
||||
<p className="font-bold mb-0.5">Accept New Keys</p>
|
||||
<p>
|
||||
This will remove the node from device and request new keys.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={handleNodeRemove}
|
||||
className=""
|
||||
>
|
||||
Request New Keys
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCloseDialog}
|
||||
className=""
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
{/* </DialogDescription> */}
|
||||
</DialogContent>
|
||||
</Dialog >
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
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";
|
||||
|
||||
vi.mock("@core/stores/appStore.ts", () => ({
|
||||
useAppStore: vi.fn(() => ({ activeChat: "chat-123" })),
|
||||
}));
|
||||
|
||||
vi.mock("@core/stores/deviceStore.ts", () => ({
|
||||
useDevice: vi.fn(() => ({
|
||||
removeNode: vi.fn(),
|
||||
setDialogOpen: vi.fn(),
|
||||
getNodeError: vi.fn(),
|
||||
clearNodeError: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("useRefreshKeysDialog Hook", () => {
|
||||
let removeNodeMock: Mock;
|
||||
let setDialogOpenMock: Mock;
|
||||
let getNodeErrorMock: Mock;
|
||||
let clearNodeErrorMock: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
removeNodeMock = vi.fn();
|
||||
setDialogOpenMock = vi.fn();
|
||||
getNodeErrorMock = vi.fn();
|
||||
clearNodeErrorMock = vi.fn();
|
||||
|
||||
(useDevice as Mock).mockReturnValue({
|
||||
removeNode: removeNodeMock,
|
||||
setDialogOpen: setDialogOpenMock,
|
||||
getNodeError: getNodeErrorMock,
|
||||
clearNodeError: clearNodeErrorMock,
|
||||
});
|
||||
});
|
||||
|
||||
it("handleNodeRemove should remove the node and update dialog if there is an error", () => {
|
||||
getNodeErrorMock.mockReturnValue({ node: "node-abc" });
|
||||
|
||||
const { result } = renderHook(() => useRefreshKeysDialog());
|
||||
|
||||
act(() => {
|
||||
result.current.handleNodeRemove();
|
||||
});
|
||||
|
||||
expect(getNodeErrorMock).toHaveBeenCalledWith("chat-123");
|
||||
expect(clearNodeErrorMock).toHaveBeenCalledWith("chat-123");
|
||||
expect(removeNodeMock).toHaveBeenCalledWith("node-abc");
|
||||
expect(setDialogOpenMock).toHaveBeenCalledWith("refreshKeys", false);
|
||||
});
|
||||
|
||||
it("handleNodeRemove should do nothing if there is no error", () => {
|
||||
getNodeErrorMock.mockReturnValue(undefined);
|
||||
|
||||
const { result } = renderHook(() => useRefreshKeysDialog());
|
||||
|
||||
act(() => {
|
||||
result.current.handleNodeRemove();
|
||||
});
|
||||
|
||||
expect(removeNodeMock).not.toHaveBeenCalled();
|
||||
expect(setDialogOpenMock).not.toHaveBeenCalled();
|
||||
expect(clearNodeErrorMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handleCloseDialog should close the dialog", () => {
|
||||
const { result } = renderHook(() => useRefreshKeysDialog());
|
||||
|
||||
act(() => {
|
||||
result.current.handleCloseDialog();
|
||||
});
|
||||
|
||||
expect(setDialogOpenMock).toHaveBeenCalledWith("refreshKeys", false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useCallback } from "react";
|
||||
import { useAppStore } from "@core/stores/appStore.ts";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
|
||||
export function useRefreshKeysDialog() {
|
||||
const { removeNode, setDialogOpen, clearNodeError, getNodeError } = useDevice();
|
||||
const { activeChat } = useAppStore();
|
||||
|
||||
const handleNodeRemove = useCallback(() => {
|
||||
const nodeWithError = getNodeError(activeChat);
|
||||
if (!nodeWithError) {
|
||||
return;
|
||||
}
|
||||
clearNodeError(activeChat);
|
||||
handleCloseDialog();;
|
||||
return removeNode(nodeWithError?.node);
|
||||
}, [activeChat, clearNodeError, setDialogOpen, removeNode]);
|
||||
|
||||
const handleCloseDialog = useCallback(() => {
|
||||
setDialogOpen('refreshKeys', false);
|
||||
}, [setDialogOpen])
|
||||
|
||||
return {
|
||||
handleCloseDialog,
|
||||
handleNodeRemove
|
||||
};
|
||||
|
||||
}
|
||||
@@ -124,7 +124,7 @@ export function DynamicForm<T extends FieldValues>({
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
{hasSubmitButton && <Button type="submit">Submit</Button>}
|
||||
{hasSubmitButton && <Button type="submit" variant="outline">Submit</Button>}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,12 +34,8 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
|
||||
settings: {
|
||||
...data.settings,
|
||||
psk: toByteArray(pass),
|
||||
moduleSettings: {
|
||||
positionPrecision: data.settings.positionEnabled
|
||||
? data.settings.preciseLocation
|
||||
? 32
|
||||
: data.settings.positionPrecision
|
||||
: 0,
|
||||
moduleSettings: {...data.settings.moduleSettings,
|
||||
positionPrecision: data.settings.moduleSettings.positionPrecision,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -100,17 +96,9 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
|
||||
settings: {
|
||||
...channel?.settings,
|
||||
psk: pass,
|
||||
positionEnabled:
|
||||
channel?.settings?.moduleSettings?.positionPrecision !==
|
||||
undefined &&
|
||||
channel?.settings?.moduleSettings?.positionPrecision > 0,
|
||||
preciseLocation:
|
||||
channel?.settings?.moduleSettings?.positionPrecision === 32,
|
||||
positionPrecision:
|
||||
channel?.settings?.moduleSettings?.positionPrecision ===
|
||||
undefined
|
||||
? 10
|
||||
: channel?.settings?.moduleSettings?.positionPrecision,
|
||||
moduleSettings: {...channel?.settings?.moduleSettings,
|
||||
positionPrecision: channel?.settings?.moduleSettings?.positionPrecision === undefined ? 10 : channel?.settings?.moduleSettings?.positionPrecision,
|
||||
}
|
||||
},
|
||||
},
|
||||
}}
|
||||
@@ -174,39 +162,30 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
|
||||
label: "Downlink Enabled",
|
||||
description: "Send messages from MQTT to the local mesh",
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "settings.positionEnabled",
|
||||
label: "Allow Position Requests",
|
||||
description: "Send position to channel",
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "settings.preciseLocation",
|
||||
label: "Precise Location",
|
||||
description: "Send precise location to channel",
|
||||
},
|
||||
{
|
||||
type: "select",
|
||||
name: "settings.positionPrecision",
|
||||
label: "Approximate Location",
|
||||
name: "settings.moduleSettings.positionPrecision",
|
||||
label: "Location",
|
||||
description:
|
||||
"If not sharing precise location, position shared on channel will be accurate within this distance",
|
||||
"The precision of the location to share with the channel. Can be disabled.",
|
||||
properties: {
|
||||
enumValue: config.display?.units === 0
|
||||
? {
|
||||
"Within 23 km": 10,
|
||||
"Within 12 km": 11,
|
||||
"Within 5.8 km": 12,
|
||||
"Within 2.9 km": 13,
|
||||
"Within 1.5 km": 14,
|
||||
"Within 700 m": 15,
|
||||
"Within 350 m": 16,
|
||||
"Within 200 m": 17,
|
||||
"Within 90 m": 18,
|
||||
"Within 50 m": 19,
|
||||
"Do not share location": 0,
|
||||
"Within 23 kilometers": 10,
|
||||
"Within 12 kilometers": 11,
|
||||
"Within 5.8 kilometers": 12,
|
||||
"Within 2.9 kilometers": 13,
|
||||
"Within 1.5 kilometers": 14,
|
||||
"Within 700 meters": 15,
|
||||
"Within 350 meters": 16,
|
||||
"Within 200 meters": 17,
|
||||
"Within 90 meters": 18,
|
||||
"Within 50 meters": 19,
|
||||
"Precise Location": 32,
|
||||
}
|
||||
: {
|
||||
"Do not share location": 0,
|
||||
"Within 15 miles": 10,
|
||||
"Within 7.3 miles": 11,
|
||||
"Within 3.6 miles": 12,
|
||||
@@ -217,6 +196,7 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
|
||||
"Within 600 feet": 17,
|
||||
"Within 300 feet": 18,
|
||||
"Within 150 feet": 19,
|
||||
"Precise Location": 32,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -18,9 +18,7 @@ export const Serial = ({ closeDialog }: TabElementProps) => {
|
||||
setSerialPorts(await navigator?.serial.getPorts());
|
||||
}, []);
|
||||
|
||||
navigator?.serial?.addEventListener("connect", (event) => {
|
||||
console.log(event);
|
||||
|
||||
navigator?.serial?.addEventListener("connect", () => {
|
||||
updateSerialPortList();
|
||||
});
|
||||
navigator?.serial?.addEventListener("disconnect", () => {
|
||||
@@ -47,8 +45,6 @@ export const Serial = ({ closeDialog }: TabElementProps) => {
|
||||
<div className="flex w-full flex-col gap-2 p-4">
|
||||
<div className="flex h-48 flex-col gap-2 overflow-y-auto">
|
||||
{serialPorts.map((port, index) => {
|
||||
console.log(port);
|
||||
|
||||
const { usbProductId, usbVendorId } = port.getInfo();
|
||||
return (
|
||||
<Button
|
||||
|
||||
@@ -15,9 +15,10 @@ const EmptyState = () => (
|
||||
);
|
||||
|
||||
export const ChannelChat = ({
|
||||
messages,
|
||||
messages = [],
|
||||
}: ChannelChatProps) => {
|
||||
const { nodes } = useDevice();
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -57,7 +58,7 @@ export const ChannelChat = ({
|
||||
className="flex-1 overflow-y-auto pl-4 pr-4 md:pr-44"
|
||||
>
|
||||
<div className="flex flex-col justify-end min-h-full">
|
||||
{messages.map((message, index) => (
|
||||
{messages?.map((message, index) => (
|
||||
<Message
|
||||
key={message.id}
|
||||
message={message}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { memo, useMemo } from "react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipArrow,
|
||||
@@ -12,15 +13,13 @@ import {
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import { Avatar } from "@components/UI/Avatar.tsx";
|
||||
import type { Protobuf } from "@meshtastic/core";
|
||||
import { AlertCircle, CheckCircle2, CircleEllipsis } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { AlertCircle, CheckCircle2, CircleEllipsis, LucideIcon } from "lucide-react";
|
||||
|
||||
const MESSAGE_STATES = {
|
||||
ACK: "ack",
|
||||
WAITING: "waiting",
|
||||
FAILED: "failed",
|
||||
} as const;
|
||||
type MessageStateValue = {
|
||||
state: string;
|
||||
icon: LucideIcon;
|
||||
displayText: string;
|
||||
}
|
||||
|
||||
type MessageState = MessageWithState["state"];
|
||||
|
||||
@@ -40,31 +39,36 @@ interface StatusIconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const STATUS_TEXT_MAP: Record<MessageState, string> = {
|
||||
[MESSAGE_STATES.ACK]: "Message delivered",
|
||||
[MESSAGE_STATES.WAITING]: "Waiting for delivery",
|
||||
[MESSAGE_STATES.FAILED]: "Delivery failed",
|
||||
const MESSAGE_STATES: Record<string, MessageStateValue> = {
|
||||
ACK: { state: 'ack', icon: CheckCircle2, displayText: "Message delivered" },
|
||||
WAITING: { state: 'waiting', icon: CircleEllipsis, displayText: "Waiting for delivery" },
|
||||
FAILED: { state: 'failed', icon: AlertCircle, displayText: "Delivery failed" },
|
||||
};
|
||||
|
||||
const STATUS_ICON_MAP: Record<MessageState, LucideIcon> = {
|
||||
[MESSAGE_STATES.ACK]: CheckCircle2,
|
||||
[MESSAGE_STATES.WAITING]: CircleEllipsis,
|
||||
[MESSAGE_STATES.FAILED]: AlertCircle,
|
||||
};
|
||||
|
||||
const getStatusText = (state: MessageState): string => STATUS_TEXT_MAP[state];
|
||||
const getMessageState = (state: MessageState): MessageStateValue => {
|
||||
switch (state) {
|
||||
case MESSAGE_STATES.ACK.state:
|
||||
return MESSAGE_STATES.ACK;
|
||||
case MESSAGE_STATES.WAITING.state:
|
||||
return MESSAGE_STATES.WAITING;
|
||||
case MESSAGE_STATES.FAILED.state:
|
||||
return MESSAGE_STATES.FAILED;
|
||||
default:
|
||||
return MESSAGE_STATES.FAILED;
|
||||
}
|
||||
}
|
||||
|
||||
const StatusTooltip = ({ state, children }: StatusTooltipProps) => (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
className="rounded-md bg-slate-800 px-3 py-1.5 text-sm text-white shadow-md animate-in fade-in-0 zoom-in-95"
|
||||
className="rounded-md bg-slate-800 px-3 py-1.5 text-sm text-white dark:text-white shadow-md animate-in fade-in-0 zoom-in-95"
|
||||
side="top"
|
||||
align="center"
|
||||
sideOffset={5}
|
||||
>
|
||||
{getStatusText(state)}
|
||||
{getMessageState(state).displayText ?? "An unknown error occurred"};
|
||||
<TooltipArrow className="fill-slate-800" />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -72,13 +76,17 @@ const StatusTooltip = ({ state, children }: StatusTooltipProps) => (
|
||||
);
|
||||
|
||||
const StatusIcon = ({ state, className, ...otherProps }: StatusIconProps) => {
|
||||
const isFailed = state === MESSAGE_STATES.FAILED;
|
||||
const msgState = getMessageState(state);
|
||||
|
||||
const isFailed = msgState.state === 'failed'
|
||||
|
||||
const iconClass = cn(
|
||||
className,
|
||||
"text-slate-500 dark:text-slate-400 w-4 h-4 shrink-0",
|
||||
"text-slate-500 dark:text-slate-400 size-5 shrink-0"
|
||||
);
|
||||
|
||||
const Icon = STATUS_ICON_MAP[state];
|
||||
const Icon = msgState.icon;
|
||||
|
||||
return (
|
||||
<StatusTooltip state={state}>
|
||||
<Icon
|
||||
@@ -90,23 +98,7 @@ const StatusIcon = ({ state, className, ...otherProps }: StatusIconProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const getMessageTextStyles = (state: MessageState) => {
|
||||
const isAcknowledged = state === MESSAGE_STATES.ACK;
|
||||
const isFailed = state === MESSAGE_STATES.FAILED;
|
||||
|
||||
return cn(
|
||||
"break-words overflow-hidden",
|
||||
isAcknowledged
|
||||
? "text-slate-900 dark:text-white"
|
||||
: "text-slate-900 dark:text-slate-400",
|
||||
isFailed && "text-red-500 dark:text-red-500",
|
||||
);
|
||||
};
|
||||
|
||||
const TimeDisplay = ({
|
||||
date,
|
||||
className,
|
||||
}: { date: Date; className?: string }) => (
|
||||
const TimeDisplay = memo(({ date, className }: { date: Date; className?: string }) => (
|
||||
<div className={cn("flex items-center gap-2 shrink-0", className)}>
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400 font-mono">
|
||||
{date.toLocaleDateString()}
|
||||
@@ -118,9 +110,9 @@ const TimeDisplay = ({
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
));
|
||||
|
||||
export const Message = ({ lastMsgSameUser, message, sender }: MessageProps) => {
|
||||
export const Message = memo(({ lastMsgSameUser, message, sender }: MessageProps) => {
|
||||
const { getDevices } = useDeviceStore();
|
||||
|
||||
const isDeviceUser = useMemo(
|
||||
@@ -128,33 +120,47 @@ export const Message = ({ lastMsgSameUser, message, sender }: MessageProps) => {
|
||||
getDevices()
|
||||
.map((device) => device.nodes.get(device.hardware.myNodeNum)?.num)
|
||||
.includes(message.from),
|
||||
[getDevices, message.from],
|
||||
[getDevices, message.from]
|
||||
);
|
||||
|
||||
const messageUser = sender?.user;
|
||||
|
||||
const messageTextClass = getMessageTextStyles(message.state);
|
||||
const getMessageTextStyles = (state: MessageState) => {
|
||||
const msgState = getMessageState(state);
|
||||
const isAcknowledged = msgState.state === 'ack'
|
||||
const isFailed = msgState.state === 'failed'
|
||||
|
||||
return cn(
|
||||
"break-words overflow-hidden",
|
||||
isAcknowledged
|
||||
? "text-slate-900 dark:text-white"
|
||||
: "text-slate-900 dark:text-slate-400",
|
||||
isFailed && "text-red-500 dark:text-red-500",
|
||||
);
|
||||
};
|
||||
|
||||
const messageTextClass = useMemo(() => getMessageTextStyles(message.state), [message.state]);
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full px-4 justify-start">
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col flex-wrap items-start py-1",
|
||||
isDeviceUser && "items-end",
|
||||
isDeviceUser && "items-end"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{!lastMsgSameUser
|
||||
? (
|
||||
<div className="flex place-items-center gap-2 mb-1">
|
||||
<Avatar text={messageUser?.shortName ?? "UNK"} />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-slate-900 dark:text-white truncate">
|
||||
{messageUser?.longName}
|
||||
</span>
|
||||
</div>
|
||||
{!lastMsgSameUser && (
|
||||
<div className="flex place-items-center gap-2 mb-1">
|
||||
<Avatar text={messageUser?.shortName ?? "UNK"} />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-slate-900 dark:text-white truncate">
|
||||
{messageUser?.longName}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<TimeDisplay date={message.rxTime} />
|
||||
<div className="flex place-items-center gap-2 pb-2">
|
||||
@@ -166,4 +172,4 @@ export const Message = ({ lastMsgSameUser, message, sender }: MessageProps) => {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipArrow,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@components/UI/Tooltip.tsx";
|
||||
import { useDeviceStore } from "@core/stores/deviceStore.ts";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import { Avatar } from "@components/UI/Avatar.tsx";
|
||||
import { AlertCircle, CheckCircle2, CircleEllipsis } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { ReactNode, useMemo } from "react";
|
||||
import { Message, MessageState } from "@core/services/types.ts";
|
||||
|
||||
interface MessageProps {
|
||||
lastMsgSameUser: boolean;
|
||||
message: Message;
|
||||
}
|
||||
|
||||
interface MessageStatus {
|
||||
state: MessageState;
|
||||
displayText: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
const MESSAGE_STATUS: Record<MessageState, MessageStatus> = {
|
||||
ack: { state: "ack", displayText: "Message delivered", icon: CheckCircle2 },
|
||||
waiting: { state: "waiting", displayText: "Waiting for delivery", icon: CircleEllipsis },
|
||||
failed: { state: "failed", displayText: "Delivery failed", icon: AlertCircle },
|
||||
};
|
||||
|
||||
const getMessageStatus = (state: MessageState): MessageStatus =>
|
||||
MESSAGE_STATUS[state] || { state: "failed", displayText: "Unknown error", icon: AlertCircle };
|
||||
|
||||
const StatusTooltip = ({ status, children }: { status: MessageStatus; children: ReactNode }) => (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
className="rounded-md bg-slate-800 px-3 py-1.5 text-sm text-white shadow-md animate-in fade-in-0 zoom-in-95"
|
||||
side="top"
|
||||
align="center"
|
||||
sideOffset={5}
|
||||
>
|
||||
{status.displayText}
|
||||
<TooltipArrow className="fill-slate-800" />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
const StatusIcon = ({ status, className, ...otherProps }: { status: MessageStatus; className?: string }) => {
|
||||
const isFailed = status.state === "failed";
|
||||
const iconClass = cn("text-slate-500 dark:text-slate-400 w-4 h-4 shrink-0", className);
|
||||
const Icon = status.icon;
|
||||
|
||||
return (
|
||||
<StatusTooltip status={status}>
|
||||
<Icon className={iconClass} {...otherProps} color={isFailed ? "red" : "currentColor"} />
|
||||
</StatusTooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const getMessageTextStyles = (status: MessageStatus) => {
|
||||
const isAcknowledged = status.state === "ack";
|
||||
const isFailed = status.state === "failed";
|
||||
|
||||
return cn(
|
||||
"break-words overflow-hidden",
|
||||
isAcknowledged ? "text-slate-900 dark:text-white" : "text-slate-900 dark:text-slate-400",
|
||||
isFailed && "text-red-500 dark:text-red-500",
|
||||
);
|
||||
};
|
||||
|
||||
const TimeDisplay = ({ date, className }: { date: Date; className?: string }) => (
|
||||
<div className={cn("flex items-center gap-2 shrink-0", className)}>
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400 font-mono">{date.toLocaleDateString()}</span>
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400 font-mono">
|
||||
{date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const MessageItem = ({ lastMsgSameUser, message }: MessageProps) => {
|
||||
const { getDevices } = useDeviceStore();
|
||||
|
||||
const isDeviceUser = useMemo(
|
||||
() =>
|
||||
getDevices()
|
||||
.map((device) => device.nodes.get(device.hardware.myNodeNum)?.num)
|
||||
.includes(message.from),
|
||||
[getDevices, message.from],
|
||||
);
|
||||
|
||||
const messageUser = message?.from
|
||||
? getDevices().find((device) => device.nodes.has(message.from))?.nodes.get(message.from)
|
||||
: null;
|
||||
|
||||
const messageStatus = getMessageStatus(message.state);
|
||||
const messageTextClass = getMessageTextStyles(messageStatus);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full px-4 justify-start">
|
||||
<div className={cn("flex flex-col flex-wrap items-start py-1", messageTextClass, isDeviceUser && "items-end")}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{!lastMsgSameUser && (
|
||||
<div className="flex place-items-center gap-2 mb-1">
|
||||
<Avatar text={messageUser?.user?.shortName ?? "UNK"} />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-slate-900 dark:text-white truncate">
|
||||
{messageUser?.user?.longName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<TimeDisplay date={message.date} />
|
||||
<div className="flex place-items-center gap-2 pb-2">
|
||||
<div className={cn(isDeviceUser && "pl-11", messageTextClass)}>{message.message}</div>
|
||||
<StatusIcon status={messageStatus} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect, vi, beforeEach, Mock } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { TraceRoute } from "@components/PageComponents/Messages/TraceRoute.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
|
||||
vi.mock("@core/stores/deviceStore");
|
||||
|
||||
describe("TraceRoute", () => {
|
||||
const mockNodes = new Map([
|
||||
[
|
||||
1,
|
||||
{ num: 1, user: { longName: "Node A" } },
|
||||
],
|
||||
[
|
||||
2,
|
||||
{ num: 2, user: { longName: "Node B" } },
|
||||
],
|
||||
[
|
||||
3,
|
||||
{ num: 3, user: { longName: "Node C" } },
|
||||
],
|
||||
]);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
(useDevice as Mock).mockReturnValue({
|
||||
nodes: mockNodes,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the route to destination with SNR values", () => {
|
||||
render(
|
||||
<TraceRoute
|
||||
from={{ user: { longName: "Source Node" } } as any}
|
||||
to={{ user: { longName: "Destination Node" } } as any}
|
||||
route={[1, 2]}
|
||||
snrTowards={[10, 20, 30]}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Route to destination:")).toBeInTheDocument();
|
||||
expect(screen.getByText("Destination Node")).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText("Node A")).toBeInTheDocument();
|
||||
expect(screen.getByText("Node B")).toBeInTheDocument();
|
||||
|
||||
expect(screen.getAllByText(/↓/)).toHaveLength(3); // startNode + 2 hops
|
||||
expect(screen.getByText("↓ 10dB")).toBeInTheDocument();
|
||||
expect(screen.getByText("↓ 20dB")).toBeInTheDocument();
|
||||
expect(screen.getByText("↓ 30dB")).toBeInTheDocument();
|
||||
expect(screen.getByText("Source Node")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the route back when provided", () => {
|
||||
render(
|
||||
<TraceRoute
|
||||
from={{ user: { longName: "Source Node" } } as any}
|
||||
to={{ user: { longName: "Destination Node" } } as any}
|
||||
route={[1]}
|
||||
snrTowards={[15, 25]}
|
||||
routeBack={[3]}
|
||||
snrBack={[35, 45]}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Route back:")).toBeInTheDocument();
|
||||
expect(screen.getByText("Node C")).toBeInTheDocument();
|
||||
expect(screen.getByText("↓ 35dB")).toBeInTheDocument();
|
||||
expect(screen.getByText("↓ 45dB")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders '??' for missing SNR values", () => {
|
||||
render(
|
||||
<TraceRoute
|
||||
from={{ user: { longName: "Source" } } as any}
|
||||
to={{ user: { longName: "Dest" } } as any}
|
||||
route={[1]}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getAllByText("↓ ??dB").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders hop hex if node is not found", () => {
|
||||
render(
|
||||
<TraceRoute
|
||||
from={{ user: { longName: "Source" } } as any}
|
||||
to={{ user: { longName: "Dest" } } as any}
|
||||
route={[99]}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/^!63$/)).toBeInTheDocument(); // 99 in hex
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,33 @@ export interface TraceRouteProps {
|
||||
snrBack?: Array<number>;
|
||||
}
|
||||
|
||||
interface RoutePathProps {
|
||||
title: string;
|
||||
startNode?: Protobuf.Mesh.NodeInfo;
|
||||
endNode?: Protobuf.Mesh.NodeInfo;
|
||||
path: number[];
|
||||
snr?: number[];
|
||||
}
|
||||
|
||||
const RoutePath = ({ title, startNode, endNode, path, snr }: RoutePathProps) => {
|
||||
const { nodes } = useDevice();
|
||||
|
||||
return (
|
||||
<span className="ml-4 border-l-2 border-l-background-primary pl-2 text-slate-900 dark:text-slate-900">
|
||||
<p className="font-semibold">{title}</p>
|
||||
<p>{startNode?.user?.longName}</p>
|
||||
<p>↓ {snr?.[0] ?? "??"}dB</p>
|
||||
{path.map((hop, i) => (
|
||||
<span key={nodes.get(hop)?.num ?? hop}>
|
||||
<p>{nodes.get(hop)?.user?.longName ?? `!${numberToHexUnpadded(hop)}`}</p>
|
||||
<p>↓ {snr?.[i + 1] ?? "??"}dB</p>
|
||||
</span>
|
||||
))}
|
||||
<p>{endNode?.user?.longName}</p>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export const TraceRoute = ({
|
||||
from,
|
||||
to,
|
||||
@@ -19,43 +46,24 @@ export const TraceRoute = ({
|
||||
snrTowards,
|
||||
snrBack,
|
||||
}: TraceRouteProps) => {
|
||||
const { nodes } = useDevice();
|
||||
|
||||
return (
|
||||
<div className="ml-5 flex">
|
||||
<span className="ml-4 border-l-2 border-l-background-primary pl-2 text-text-primary">
|
||||
<p className="font-semibold">Route to destination:</p>
|
||||
<p>{to?.user?.longName}</p>
|
||||
<p>↓ {snrTowards?.[0] ? snrTowards[0] : "??"}dB</p>
|
||||
{route.map((hop, i) => (
|
||||
<span key={nodes.get(hop)?.num}>
|
||||
<p>
|
||||
{nodes.get(hop)?.user?.longName ?? `!${numberToHexUnpadded(hop)}`}
|
||||
</p>
|
||||
<p>↓ {snrTowards?.[i + 1] ? snrTowards[i + 1] : "??"}dB</p>
|
||||
</span>
|
||||
))}
|
||||
{from?.user?.longName}
|
||||
</span>
|
||||
{routeBack
|
||||
? (
|
||||
<span className="ml-4 border-l-2 border-l-background-primary pl-2 text-text-primary">
|
||||
<p className="font-semibold">Route back:</p>
|
||||
<p>{from?.user?.longName}</p>
|
||||
<p>↓ {snrBack?.[0] ? snrBack[0] : "??"}dB</p>
|
||||
{routeBack.map((hop, i) => (
|
||||
<span key={nodes.get(hop)?.num}>
|
||||
<p>
|
||||
{nodes.get(hop)?.user?.longName ??
|
||||
`!${numberToHexUnpadded(hop)}`}
|
||||
</p>
|
||||
<p>↓ {snrBack?.[i + 1] ? snrBack[i + 1] : "??"}dB</p>
|
||||
</span>
|
||||
))}
|
||||
{to?.user?.longName}
|
||||
</span>
|
||||
)
|
||||
: null}
|
||||
<RoutePath
|
||||
title="Route to destination:"
|
||||
startNode={to}
|
||||
endNode={from}
|
||||
path={route}
|
||||
snr={snrTowards}
|
||||
/>
|
||||
{routeBack && (
|
||||
<RoutePath
|
||||
title="Route back:"
|
||||
startNode={from}
|
||||
endNode={to}
|
||||
path={routeBack}
|
||||
snr={snrBack}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -58,7 +58,7 @@ export const Sidebar = ({ children }: SidebarProps) => {
|
||||
page: "channels",
|
||||
},
|
||||
{
|
||||
name: "Nodes",
|
||||
name: `Nodes (${nodes.size})`,
|
||||
icon: UsersIcon,
|
||||
page: "nodes",
|
||||
},
|
||||
|
||||
@@ -24,18 +24,25 @@ export default function ThemeSwitcher({
|
||||
setPreference(nextPreference);
|
||||
};
|
||||
|
||||
const [firstCharOfPreference = "", ...restOfPreference] = preference;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"transition-all duration-300 scale-100 cursor-pointer m-6 p-2",
|
||||
"transition-all duration-300 scale-100 cursor-pointer m-6 p-2 focus:*:data-label:opacity-100",
|
||||
className,
|
||||
)}
|
||||
onClick={toggleTheme}
|
||||
aria-label={preference === "system"
|
||||
? `System theme (currently ${theme}). Click to change theme.`
|
||||
: `Current theme: ${theme}. Click to change 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"
|
||||
>
|
||||
{firstCharOfPreference.toLocaleUpperCase() +
|
||||
(restOfPreference ?? []).join("")}
|
||||
</span>
|
||||
{themeIcons[preference]}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cn } from "../../core/utils/cn.ts";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import { LockKeyholeOpenIcon } from 'lucide-react';
|
||||
import type React from "react";
|
||||
|
||||
type RGBColor = {
|
||||
@@ -12,6 +13,7 @@ interface AvatarProps {
|
||||
text: string;
|
||||
size?: "sm" | "lg";
|
||||
className?: string;
|
||||
showError?: boolean;
|
||||
}
|
||||
|
||||
// biome-ignore lint/complexity/noStaticOnlyClass: stop being annoying Biome
|
||||
@@ -43,6 +45,7 @@ class ColorUtils {
|
||||
export const Avatar: React.FC<AvatarProps> = ({
|
||||
text,
|
||||
size = "sm",
|
||||
showError = false,
|
||||
className,
|
||||
}) => {
|
||||
const sizes = {
|
||||
@@ -73,12 +76,11 @@ export const Avatar: React.FC<AvatarProps> = ({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
`
|
||||
`flex
|
||||
relative
|
||||
rounded-full
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
size-11
|
||||
font-semibold`,
|
||||
sizes[size],
|
||||
className,
|
||||
@@ -88,6 +90,7 @@ 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>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -15,7 +15,7 @@ const buttonVariants = cva(
|
||||
success:
|
||||
"bg-green-500 text-white hover:bg-green-600 dark:hover:bg-green-600",
|
||||
outline:
|
||||
"bg-transparent border border-slate-200 hover:bg-slate-100 dark:border-slate-400 dark:text-slate-500",
|
||||
"bg-transparent border border-slate-400 hover:bg-slate-100 dark:border-slate-400 dark:text-slate-500",
|
||||
subtle:
|
||||
"bg-slate-100 text-slate-700 hover:bg-slate-200 dark:bg-slate-500 dark:text-white dark:hover:bg-slate-400",
|
||||
ghost:
|
||||
|
||||
@@ -92,7 +92,7 @@ export const Table = ({ headings, rows }: TableProps) => {
|
||||
<th
|
||||
key={heading.title}
|
||||
scope="col"
|
||||
className={`py-2 pr-3 pl-6 text-left ${
|
||||
className={`py-2 pr-3 text-left ${
|
||||
heading.sortable
|
||||
? "cursor-pointer hover:brightness-hover active:brightness-press"
|
||||
: ""
|
||||
@@ -114,8 +114,16 @@ export const Table = ({ headings, rows }: TableProps) => {
|
||||
<tbody>
|
||||
{sortedRows.map((row, index) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: TODO: Once this table is sortable, this should get fixed.
|
||||
<tr key={index}>
|
||||
<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"
|
||||
|
||||
@@ -27,12 +27,18 @@ export type DialogVariant =
|
||||
| "nodeRemoval"
|
||||
| "pkiBackup"
|
||||
| "nodeDetails"
|
||||
| "unsafeRoles";
|
||||
| "unsafeRoles"
|
||||
| "refreshKeys";
|
||||
|
||||
type QueueStatus = {
|
||||
res: number, free: number, maxlen: number
|
||||
}
|
||||
|
||||
type NodeError = {
|
||||
node: number;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface Device {
|
||||
id: number;
|
||||
status: Types.DeviceStatusEnum;
|
||||
@@ -52,6 +58,7 @@ export interface Device {
|
||||
number,
|
||||
Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>[]
|
||||
>;
|
||||
nodeErrors: Map<number, NodeError>;
|
||||
connection?: MeshDevice;
|
||||
activePage: Page;
|
||||
activeNode: number;
|
||||
@@ -71,6 +78,7 @@ export interface Device {
|
||||
pkiBackup: boolean;
|
||||
nodeDetails: boolean;
|
||||
unsafeRoles: boolean;
|
||||
refreshKeys: boolean;
|
||||
};
|
||||
unreadCounts: Map<number, number>;
|
||||
|
||||
@@ -111,6 +119,10 @@ export interface Device {
|
||||
setMessageDraft: (message: string) => void;
|
||||
setUnread: (id: number, count: number) => void;
|
||||
setQueueStatus: (status: QueueStatus) => void;
|
||||
setNodeError: (nodeNum: number, error: string) => void;
|
||||
clearNodeError: (nodeNum: number) => void;
|
||||
getNodeError: (nodeNum: number) => NodeError | undefined;
|
||||
hasNodeError: (nodeNum: number) => boolean
|
||||
}
|
||||
|
||||
export interface DeviceState {
|
||||
@@ -164,10 +176,12 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
||||
pkiBackup: false,
|
||||
nodeDetails: false,
|
||||
unsafeRoles: false,
|
||||
refreshKeys: false,
|
||||
},
|
||||
pendingSettingsChanges: false,
|
||||
messageDraft: "",
|
||||
unreadCounts: new Map(),
|
||||
nodeErrors: new Map(),
|
||||
|
||||
setStatus: (status: Types.DeviceStatusEnum) => {
|
||||
set(
|
||||
@@ -534,7 +548,6 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
||||
addTraceRoute: (traceroute) => {
|
||||
set(
|
||||
produce<DeviceState>((draft) => {
|
||||
console.log("addTraceRoute called");
|
||||
const device = draft.devices.get(id);
|
||||
if (!device) {
|
||||
return;
|
||||
@@ -571,10 +584,8 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
||||
) => {
|
||||
set(
|
||||
produce<DeviceState>((draft) => {
|
||||
console.log("setMessageState called");
|
||||
const device = draft.devices.get(id);
|
||||
if (!device) {
|
||||
console.log("no device found for id");
|
||||
return;
|
||||
}
|
||||
const messageGroup = device.messages[type];
|
||||
@@ -585,7 +596,6 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
||||
const messages = messageGroup.get(messageIndex);
|
||||
|
||||
if (!messages) {
|
||||
console.log("no messages found for id");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -680,7 +690,42 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
setNodeError: (nodeNum, error) => {
|
||||
set(
|
||||
produce<DeviceState>((draft) => {
|
||||
const device = draft.devices.get(id);
|
||||
if (device) {
|
||||
device.nodeErrors.set(nodeNum, { node: nodeNum, error });
|
||||
}
|
||||
}),
|
||||
);
|
||||
},
|
||||
clearNodeError: (nodeNum: number) => {
|
||||
set(
|
||||
produce<DeviceState>((draft) => {
|
||||
const device = draft.devices.get(id);
|
||||
if (device) {
|
||||
device.nodeErrors.delete(nodeNum);
|
||||
}
|
||||
}),
|
||||
);
|
||||
},
|
||||
getNodeError: (nodeNum: number) => {
|
||||
const device = get().devices.get(id);
|
||||
if (!device) {
|
||||
throw new Error("Device not found");
|
||||
}
|
||||
return device.nodeErrors.get(nodeNum);
|
||||
},
|
||||
hasNodeError: (nodeNum: number) => {
|
||||
const device = get().devices.get(id);
|
||||
if (!device) {
|
||||
throw new Error("Device not found");
|
||||
}
|
||||
return device.nodeErrors.has(nodeNum);
|
||||
},
|
||||
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
+24
-10
@@ -23,15 +23,15 @@ export const subscribeAll = (
|
||||
) {
|
||||
return;
|
||||
}
|
||||
console.log(`Routing Error: ${routingPacket.data.variant.value}`);
|
||||
console.info(`Routing Error: ${routingPacket.data.variant.value}`);
|
||||
break;
|
||||
}
|
||||
case "routeReply": {
|
||||
console.log(`Route Reply: ${routingPacket.data.variant.value}`);
|
||||
console.info(`Route Reply: ${routingPacket.data.variant.value}`);
|
||||
break;
|
||||
}
|
||||
case "routeRequest": {
|
||||
console.log(`Route Request: ${routingPacket.data.variant.value}`);
|
||||
console.info(`Route Request: ${routingPacket.data.variant.value}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -71,8 +71,6 @@ export const subscribeAll = (
|
||||
});
|
||||
|
||||
connection.events.onChannelPacket.subscribe((channel) => {
|
||||
console.log('channel', channel);
|
||||
|
||||
device.addChannel(channel);
|
||||
});
|
||||
connection.events.onConfigPacket.subscribe((config) => {
|
||||
@@ -82,10 +80,8 @@ export const subscribeAll = (
|
||||
device.setModuleConfig(moduleConfig);
|
||||
});
|
||||
|
||||
|
||||
connection.events.onMessagePacket.subscribe((messagePacket) => {
|
||||
|
||||
console.log('messagePacket', messagePacket);
|
||||
|
||||
device.addMessage({
|
||||
...messagePacket,
|
||||
state: messagePacket.from !== myNodeNum ? "ack" : "waiting",
|
||||
@@ -120,8 +116,26 @@ export const subscribeAll = (
|
||||
|
||||
connection.events.onQueueStatus.subscribe((queueStatus) => {
|
||||
device.setQueueStatus(queueStatus);
|
||||
if (queueStatus.free < 10) {
|
||||
// start queueing messages
|
||||
});
|
||||
|
||||
connection.events.onRoutingPacket.subscribe((routingPacket) => {
|
||||
if (routingPacket.data.variant.case === "errorReason") {
|
||||
switch (routingPacket.data.variant.value) {
|
||||
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]);
|
||||
device.setDialogOpen("refreshKeys", true);
|
||||
break;
|
||||
case Protobuf.Mesh.Routing_Error.PKI_UNKNOWN_PUBKEY:
|
||||
console.error(`Routing Error: ${routingPacket.data.variant.value}`);
|
||||
device.setNodeError(routingPacket.from, Protobuf.Mesh.Routing_Error[routingPacket?.data?.variant?.value]);
|
||||
device.setDialogOpen("refreshKeys", true);
|
||||
break;
|
||||
default: {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { scan } from "react-scan";
|
||||
import "@app/index.css";
|
||||
import { enableMapSet } from "immer";
|
||||
import "maplibre-gl/dist/maplibre-gl.css";
|
||||
@@ -7,13 +6,6 @@ import { createRoot } from "react-dom/client";
|
||||
|
||||
import { App } from "@app/App.tsx";
|
||||
|
||||
// run react scan tool in development mode only
|
||||
// react scan must be the first import and the first line in this file in order to work properly
|
||||
import.meta.env.VITE_DEBUG_SCAN &&
|
||||
scan({
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const container = document.getElementById("root") as HTMLElement;
|
||||
const root = createRoot(container);
|
||||
|
||||
|
||||
@@ -13,9 +13,10 @@ import { getChannelName } from "@pages/Channels.tsx";
|
||||
import { HashIcon, LockIcon, LockOpenIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { MessageInput } from "@components/PageComponents/Messages/MessageInput.tsx";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
|
||||
export const MessagesPage = () => {
|
||||
const { channels, nodes, hardware, messages, unreadCounts, setUnread } = useDevice();
|
||||
const { channels, nodes, hardware, messages, hasNodeError, unreadCounts, setUnread } = useDevice();
|
||||
const { activeChat, chatType, setActiveChat, setChatType } = useAppStore();
|
||||
const [searchTerm, setSearchTerm] = useState<string>("");
|
||||
const filteredNodes = Array.from(nodes.values()).filter((node) => {
|
||||
@@ -91,6 +92,8 @@ export const MessagesPage = () => {
|
||||
element={
|
||||
<Avatar
|
||||
text={node.user?.shortName ?? node.num.toString()}
|
||||
className={cn(hasNodeError(node.num) && "text-red-500")}
|
||||
showError={hasNodeError(node.num)}
|
||||
size="sm"
|
||||
/>
|
||||
}
|
||||
@@ -154,7 +157,6 @@ export const MessagesPage = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Single message input for both chat types */}
|
||||
<div className="shrink-0 p-4 w-full dark:bg-slate-900">
|
||||
<MessageInput
|
||||
to={messageDestination}
|
||||
|
||||
+55
-56
@@ -19,10 +19,22 @@ export interface DeleteNoteDialogProps {
|
||||
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();
|
||||
console.log(connection);
|
||||
|
||||
const [selectedNode, setSelectedNode] = useState<
|
||||
Protobuf.Mesh.NodeInfo | undefined
|
||||
>(undefined);
|
||||
@@ -63,7 +75,6 @@ const NodesPage = (): JSX.Element => {
|
||||
};
|
||||
}, [connection]);
|
||||
|
||||
|
||||
const handleLocation = useCallback(
|
||||
(location: Types.PacketMetadata<Protobuf.Mesh.Position>) => {
|
||||
if (location.to.valueOf() !== hardware.myNodeNum) return;
|
||||
@@ -89,46 +100,62 @@ const NodesPage = (): JSX.Element => {
|
||||
<Table
|
||||
headings={[
|
||||
{ title: "", type: "blank", sortable: false },
|
||||
{ title: "Short Name", type: "normal", sortable: true },
|
||||
{ title: "Long Name", type: "normal", sortable: true },
|
||||
{ title: "Connection", type: "normal", sortable: true },
|
||||
{ title: "Last Heard", type: "normal", sortable: true },
|
||||
{ title: "Encryption", type: "normal", sortable: false },
|
||||
{ title: "SNR", type: "normal", sortable: true },
|
||||
{ title: "Model", type: "normal", sortable: true },
|
||||
{ title: "MAC Address", type: "normal", sortable: true },
|
||||
{ title: "Last Heard", type: "normal", sortable: true },
|
||||
{ title: "SNR", type: "normal", sortable: true },
|
||||
{ title: "Encryption", type: "normal", sortable: false },
|
||||
{ title: "Connection", type: "normal", sortable: true },
|
||||
]}
|
||||
rows={filteredNodes.map((node) => [
|
||||
<div key={node.num}>
|
||||
<Avatar text={node.user?.shortName.toString() ?? "UNK"} />
|
||||
<Avatar text={shortNameFromNode(node)} />
|
||||
</div>,
|
||||
|
||||
<h1
|
||||
key="shortName"
|
||||
onMouseDown={() => setSelectedNode(node)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{node.user?.shortName ??
|
||||
(node.user?.macaddr
|
||||
? `${base16
|
||||
.stringify(node.user?.macaddr.subarray(4, 6) ?? [])
|
||||
.toLowerCase()}`
|
||||
: `${numberToHexUnpadded(node.num).slice(-4)}`)}
|
||||
</h1>,
|
||||
|
||||
<h1
|
||||
key="longName"
|
||||
onMouseDown={() => setSelectedNode(node)}
|
||||
className="cursor-pointer"
|
||||
onKeyUp={(evt) => {
|
||||
evt.key === "Enter" && setSelectedNode(node);
|
||||
}}
|
||||
className="cursor-pointer underline"
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
>
|
||||
{node.user?.longName ??
|
||||
(node.user?.macaddr
|
||||
? `Meshtastic ${base16
|
||||
.stringify(node.user?.macaddr.subarray(4, 6) ?? [])
|
||||
.toLowerCase()}`
|
||||
? `Meshtastic ${
|
||||
base16
|
||||
.stringify(node.user?.macaddr.subarray(4, 6) ?? [])
|
||||
.toLowerCase()
|
||||
}`
|
||||
: `!${numberToHexUnpadded(node.num)}`)}
|
||||
</h1>,
|
||||
|
||||
<Mono key="hops">
|
||||
{node.lastHeard !== 0
|
||||
? node.viaMqtt === false && node.hopsAway === 0
|
||||
? "Direct"
|
||||
: `${node.hopsAway?.toString()} ${
|
||||
node.hopsAway > 1 ? "hops" : "hop"
|
||||
} away`
|
||||
: "-"}
|
||||
{node.viaMqtt === true ? ", via MQTT" : ""}
|
||||
</Mono>,
|
||||
<Mono key="lastHeard">
|
||||
{node.lastHeard === 0
|
||||
? <p>Never</p>
|
||||
: <TimeAgo timestamp={node.lastHeard * 1000} />}
|
||||
</Mono>,
|
||||
<Mono key="pki">
|
||||
{node.user?.publicKey && node.user?.publicKey.length > 0
|
||||
? <LockIcon className="text-green-600 mx-auto" />
|
||||
: <LockOpenIcon className="text-yellow-300 mx-auto" />}
|
||||
</Mono>,
|
||||
<Mono key="snr">
|
||||
{node.snr}db/
|
||||
{Math.min(Math.max((node.snr + 10) * 5, 0), 100)}%/
|
||||
{(node.snr + 10) * 5}raw
|
||||
</Mono>,
|
||||
<Mono key="model">
|
||||
{Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]}
|
||||
</Mono>,
|
||||
@@ -138,34 +165,6 @@ const NodesPage = (): JSX.Element => {
|
||||
.match(/.{1,2}/g)
|
||||
?.join(":") ?? "UNK"}
|
||||
</Mono>,
|
||||
<Mono className="px-4" key="lastHeard">
|
||||
{node.lastHeard === 0 ? (
|
||||
<p className="px-4">Never</p>
|
||||
) : (
|
||||
<TimeAgo timestamp={node.lastHeard * 1000} />
|
||||
)}
|
||||
</Mono>,
|
||||
<Mono key="snr">
|
||||
{node.snr}db/
|
||||
{Math.min(Math.max((node.snr + 10) * 5, 0), 100)}%/
|
||||
{(node.snr + 10) * 5}raw
|
||||
</Mono>,
|
||||
<Mono key="pki">
|
||||
{node.user?.publicKey && node.user?.publicKey.length > 0 ? (
|
||||
<LockIcon className="text-green-600 mx-auto" />
|
||||
) : (
|
||||
<LockOpenIcon className="text-yellow-300 mx-auto" />
|
||||
)}
|
||||
</Mono>,
|
||||
<Mono key="hops">
|
||||
{node.lastHeard !== 0
|
||||
? node.viaMqtt === false && node.hopsAway === 0
|
||||
? "Direct"
|
||||
: `${node.hopsAway?.toString()} ${node.hopsAway > 1 ? "hops" : "hop"
|
||||
} away`
|
||||
: "-"}
|
||||
{node.viaMqtt === true ? ", via MQTT" : ""}
|
||||
</Mono>,
|
||||
])}
|
||||
/>
|
||||
<NodeOptionsDialog
|
||||
|
||||
Reference in New Issue
Block a user