refactor: switch to using Bun (#718)

This commit is contained in:
Dan Ditomaso
2025-07-16 22:30:10 -04:00
committed by GitHub
parent acfd7ebd2d
commit 704d06cfe7
401 changed files with 35294 additions and 39107 deletions
+22 -25
View File
@@ -1,20 +1,19 @@
import { DeviceWrapper } from "@app/DeviceWrapper.tsx";
import { CommandPalette } from "@components/CommandPalette/index.tsx";
import { DialogManager } from "@components/Dialog/DialogManager.tsx";
import { NewDeviceDialog } from "@components/Dialog/NewDeviceDialog.tsx";
import { KeyBackupReminder } from "@components/KeyBackupReminder.tsx";
import { Toaster } from "@components/Toaster.tsx";
import { ErrorPage } from "@components/UI/ErrorPage.tsx";
import Footer from "@components/UI/Footer.tsx";
import { useTheme } from "@core/hooks/useTheme.ts";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDeviceStore } from "@core/stores/deviceStore.ts";
import { Dashboard } from "@pages/Dashboard/index.tsx";
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";
import { Dashboard } from "@pages/Dashboard/index.tsx";
import { Outlet } from "@tanstack/react-router";
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
import { ErrorBoundary } from "react-error-boundary";
import { MapProvider } from "react-map-gl/maplibre";
export function App() {
const { getDevice } = useDeviceStore();
@@ -34,7 +33,7 @@ export function App() {
setConnectDialogOpen(open);
}}
/>
<Toaster />
{/* <Toaster /> */}
<TanStackRouterDevtools position="bottom-right" />
<DeviceWrapper device={device}>
<div
@@ -43,23 +42,21 @@ export function App() {
>
<SidebarProvider>
<div className="h-full flex flex-col">
{device
? (
<div className="h-full flex w-full">
<DialogManager />
<KeyBackupReminder />
<CommandPalette />
<MapProvider>
<Outlet />
</MapProvider>
</div>
)
: (
<>
<Dashboard />
<Footer />
</>
)}
{device ? (
<div className="h-full flex w-full">
<DialogManager />
<KeyBackupReminder />
<CommandPalette />
<MapProvider>
<Outlet />
</MapProvider>
</div>
) : (
<>
<Dashboard />
<Footer />
</>
)}
</div>
</SidebarProvider>
</div>
+1 -1
View File
@@ -1,5 +1,5 @@
import { DeviceContext } from "@core/stores/deviceStore.ts";
import type { Device } from "@core/stores/deviceStore.ts";
import { DeviceContext } from "@core/stores/deviceStore.ts";
import type { ReactNode } from "react";
export interface DeviceWrapperProps {
@@ -1,7 +1,12 @@
import { vi } from "vitest";
vi.mock("@components/UI/Button.tsx", () => ({
Button: ({ children, name, disabled, onClick }: {
Button: ({
children,
name,
disabled,
onClick,
}: {
children: React.ReactNode;
variant: string;
name: string;
@@ -1,13 +1,15 @@
import { vi } from "vitest";
vi.mock("@components/UI/Checkbox.tsx", () => ({
Checkbox: (
{ id, checked, onChange }: {
id: string;
checked: boolean;
onChange: () => void;
},
) => (
Checkbox: ({
id,
checked,
onChange,
}: {
id: string;
checked: boolean;
onChange: () => void;
}) => (
<input
data-testid="checkbox"
type="checkbox"
@@ -1,10 +1,13 @@
import React from "react";
import type React from "react";
export const Dialog = ({ children, open }: {
export const Dialog = ({
children,
open,
}: {
children: React.ReactNode;
open: boolean;
onOpenChange?: (open: boolean) => void;
}) => open ? <div data-testid="dialog">{children}</div> : null;
}) => (open ? <div data-testid="dialog">{children}</div> : null);
export const DialogContent = ({
children,
@@ -12,19 +15,19 @@ export const DialogContent = ({
}: {
children: React.ReactNode;
className?: string;
}) => <div data-testid="dialog-content" className={className}>{children}</div>;
}) => (
<div data-testid="dialog-content" className={className}>
{children}
</div>
);
export const DialogHeader = ({
children,
}: {
children: React.ReactNode;
}) => <div data-testid="dialog-header">{children}</div>;
export const DialogHeader = ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-header">{children}</div>
);
export const DialogTitle = ({
children,
}: {
children: React.ReactNode;
}) => <div data-testid="dialog-title">{children}</div>;
export const DialogTitle = ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-title">{children}</div>
);
export const DialogDescription = ({
children,
@@ -33,7 +36,9 @@ export const DialogDescription = ({
children: React.ReactNode;
className?: string;
}) => (
<div data-testid="dialog-description" className={className}>{children}</div>
<div data-testid="dialog-description" className={className}>
{children}
</div>
);
export const DialogFooter = ({
@@ -42,4 +47,8 @@ export const DialogFooter = ({
}: {
children: React.ReactNode;
className?: string;
}) => <div data-testid="dialog-footer" className={className}>{children}</div>;
}) => (
<div data-testid="dialog-footer" className={className}>
{children}
</div>
);
@@ -1,13 +1,15 @@
import { vi } from "vitest";
vi.mock("@components/UI/Label.tsx", () => ({
Label: (
{ children, htmlFor, className }: {
children: React.ReactNode;
htmlFor: string;
className?: string;
},
) => (
Label: ({
children,
htmlFor,
className,
}: {
children: React.ReactNode;
htmlFor: string;
className?: string;
}) => (
<label data-testid="label" htmlFor={htmlFor} className={className}>
{children}
</label>
@@ -1,11 +1,17 @@
import { vi } from "vitest";
vi.mock("@components/UI/Typography/Link.tsx", () => ({
Link: (
{ children, href, className }: {
children: React.ReactNode;
href: string;
className?: string;
},
) => <a data-testid="link" href={href} className={className}>{children}</a>,
Link: ({
children,
href,
className,
}: {
children: React.ReactNode;
href: string;
className?: string;
}) => (
<a data-testid="link" href={href} className={className}>
{children}
</a>
),
}));
+3 -10
View File
@@ -1,12 +1,12 @@
import React from "react";
import {
BatteryFullIcon,
BatteryLowIcon,
BatteryMediumIcon,
PlugZapIcon,
} from "lucide-react";
import type React from "react";
import { useTranslation } from "react-i18next";
import { DeviceMetrics } from "./types.ts";
import type { DeviceMetrics } from "./types.ts";
type BatteryStatusKey = keyof typeof BATTERY_STATUS;
@@ -14,10 +14,6 @@ interface BatteryStatusProps {
deviceMetrics?: DeviceMetrics | null;
}
interface BatteryStatusProps {
deviceMetrics?: DeviceMetrics | null;
}
interface StatusConfig {
Icon: React.ElementType;
className: string;
@@ -89,10 +85,7 @@ const BatteryStatus: React.FC<BatteryStatusProps> = ({ deviceMetrics }) => {
} = statusConfigMap[statusKey];
return (
<div
className="flex items-center gap-1 mt-0.5 "
aria-label={t("batteryStatus.title")}
>
<div className="flex items-center gap-1 mt-0.5">
<BatteryIcon size={22} className={iconClassName} />
{statusText}
</div>
@@ -1,356 +1,358 @@
import { Avatar } from "@components/UI/Avatar.tsx";
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@components/UI/Command.tsx";
import { usePinnedItems } from "@core/hooks/usePinnedItems.ts";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDevice, useDeviceStore } from "@core/stores/deviceStore.ts";
import { cn } from "@core/utils/cn.ts";
import { useNavigate } from "@tanstack/react-router";
import { useCommandState } from "cmdk";
import {
ArrowLeftRightIcon,
BoxSelectIcon,
BugIcon,
CloudOff,
EraserIcon,
FactoryIcon,
LayersIcon,
LinkIcon,
type LucideIcon,
MapIcon,
MessageSquareIcon,
Pin,
PlusIcon,
PowerIcon,
QrCodeIcon,
RefreshCwIcon,
SettingsIcon,
SmartphoneIcon,
TrashIcon,
UsersIcon,
ArrowLeftRightIcon,
BoxSelectIcon,
BugIcon,
CloudOff,
EraserIcon,
FactoryIcon,
LayersIcon,
LinkIcon,
type LucideIcon,
MapIcon,
MessageSquareIcon,
Pin,
PlusIcon,
PowerIcon,
QrCodeIcon,
RefreshCwIcon,
SettingsIcon,
SmartphoneIcon,
TrashIcon,
UsersIcon,
} from "lucide-react";
import { useEffect } from "react";
import { Avatar } from "@components/UI/Avatar.tsx";
import { cn } from "@core/utils/cn.ts";
import { useTranslation } from "react-i18next";
import { usePinnedItems } from "@core/hooks/usePinnedItems.ts";
import { useNavigate } from "@tanstack/react-router";
export interface Group {
id: string;
label: string;
icon: LucideIcon;
commands: Command[];
id: string;
label: string;
icon: LucideIcon;
commands: Command[];
}
export interface Command {
label: string;
icon: LucideIcon;
action?: () => void;
subItems?: SubItem[];
tags?: string[];
label: string;
icon: LucideIcon;
action?: () => void;
subItems?: SubItem[];
tags?: string[];
}
export interface SubItem {
label: string;
icon: React.ReactNode;
action: () => void;
label: string;
icon: React.ReactNode;
action: () => void;
}
export const CommandPalette = () => {
const {
commandPaletteOpen,
setCommandPaletteOpen,
setConnectDialogOpen,
setSelectedDevice,
} = useAppStore();
const { getDevices } = useDeviceStore();
const { setDialogOpen, getNode, connection } = useDevice();
const { pinnedItems, togglePinnedItem } = usePinnedItems({
storageName: "pinnedCommandMenuGroups",
});
const { t } = useTranslation("commandPalette");
const navigate = useNavigate({ from: "/" });
const {
commandPaletteOpen,
setCommandPaletteOpen,
setConnectDialogOpen,
setSelectedDevice,
} = useAppStore();
const { getDevices } = useDeviceStore();
const { setDialogOpen, getNode, connection } = useDevice();
const { pinnedItems, togglePinnedItem } = usePinnedItems({
storageName: "pinnedCommandMenuGroups",
});
const { t } = useTranslation("commandPalette");
const navigate = useNavigate({ from: "/" });
const groups: Group[] = [
{
id: "gotoGroup",
label: t("goto.label"),
icon: LinkIcon,
commands: [
{
label: t("goto.command.messages"),
icon: MessageSquareIcon,
action() {
navigate({ to: "/messages" });
},
},
{
label: t("goto.command.map"),
icon: MapIcon,
action() {
navigate({ to: "/map" });
},
},
{
label: t("goto.command.config"),
icon: SettingsIcon,
action() {
navigate({ to: "/config" });
},
tags: ["settings"],
},
{
label: t("goto.command.channels"),
icon: LayersIcon,
action() {
navigate({ to: "/channels" });
},
},
{
label: t("goto.command.nodes"),
icon: UsersIcon,
action() {
navigate({ to: "/nodes" });
},
},
],
},
{
id: "manageGroup",
label: t("manage.label"),
icon: SmartphoneIcon,
commands: [
{
label: t("manage.command.switchNode"),
icon: ArrowLeftRightIcon,
subItems: getDevices().map((device) => ({
label: getNode(device.hardware.myNodeNum)?.user?.longName ??
t("unknown.shortName"),
icon: (
<Avatar
text={getNode(device.hardware.myNodeNum)?.user?.shortName ??
t("unknown.shortName")}
/>
),
action() {
setSelectedDevice(device.id);
},
})),
},
{
label: t("manage.command.connectNewNode"),
icon: PlusIcon,
action() {
setConnectDialogOpen(true);
},
},
],
},
{
id: "contextualGroup",
label: t("contextual.label"),
icon: BoxSelectIcon,
commands: [
{
label: t("contextual.command.qrCode"),
icon: QrCodeIcon,
subItems: [
{
label: t("contextual.command.qrGenerator"),
icon: <QrCodeIcon size={16} />,
action() {
setDialogOpen("QR", true);
},
},
{
label: t("contextual.command.qrImport"),
icon: <QrCodeIcon size={16} />,
action() {
setDialogOpen("import", true);
},
},
],
},
{
label: t("contextual.command.scheduleShutdown"),
icon: PowerIcon,
action() {
setDialogOpen("shutdown", true);
},
},
{
label: t("contextual.command.scheduleReboot"),
icon: RefreshCwIcon,
action() {
setDialogOpen("reboot", true);
},
},
{
label: t("contextual.command.rebootToOtaMode"),
icon: RefreshCwIcon,
action() {
setDialogOpen("rebootOTA", true);
},
},
{
label: t("contextual.command.resetNodeDb"),
icon: TrashIcon,
action() {
connection?.resetNodes();
},
},
{
label: t("contextual.command.disconnect"),
icon: CloudOff,
action() {
connection?.disconnect().catch((error) => {
console.error("Failed to disconnect:", error);
});
},
},
{
label: t("contextual.command.factoryResetDevice"),
icon: FactoryIcon,
action() {
connection?.factoryResetDevice();
},
},
{
label: t("contextual.command.factoryResetConfig"),
icon: FactoryIcon,
action() {
connection?.factoryResetConfig();
},
},
],
},
{
id: "debugGroup",
label: t("debug.label"),
icon: BugIcon,
commands: [
{
label: t("debug.command.reconfigure"),
icon: RefreshCwIcon,
action() {
void connection?.configure();
},
},
{
label: t("debug.command.clearAllStoredMessages"),
icon: EraserIcon,
action() {
setDialogOpen("deleteMessages", true);
},
},
],
},
];
const groups: Group[] = [
{
id: "gotoGroup",
label: t("goto.label"),
icon: LinkIcon,
commands: [
{
label: t("goto.command.messages"),
icon: MessageSquareIcon,
action() {
navigate({ to: "/messages" });
},
},
{
label: t("goto.command.map"),
icon: MapIcon,
action() {
navigate({ to: "/map" });
},
},
{
label: t("goto.command.config"),
icon: SettingsIcon,
action() {
navigate({ to: "/config" });
},
tags: ["settings"],
},
{
label: t("goto.command.channels"),
icon: LayersIcon,
action() {
navigate({ to: "/channels" });
},
},
{
label: t("goto.command.nodes"),
icon: UsersIcon,
action() {
navigate({ to: "/nodes" });
},
},
],
},
{
id: "manageGroup",
label: t("manage.label"),
icon: SmartphoneIcon,
commands: [
{
label: t("manage.command.switchNode"),
icon: ArrowLeftRightIcon,
subItems: getDevices().map((device) => ({
label:
getNode(device.hardware.myNodeNum)?.user?.longName ??
t("unknown.shortName"),
icon: (
<Avatar
text={
getNode(device.hardware.myNodeNum)?.user?.shortName ??
t("unknown.shortName")
}
/>
),
action() {
setSelectedDevice(device.id);
},
})),
},
{
label: t("manage.command.connectNewNode"),
icon: PlusIcon,
action() {
setConnectDialogOpen(true);
},
},
],
},
{
id: "contextualGroup",
label: t("contextual.label"),
icon: BoxSelectIcon,
commands: [
{
label: t("contextual.command.qrCode"),
icon: QrCodeIcon,
subItems: [
{
label: t("contextual.command.qrGenerator"),
icon: <QrCodeIcon size={16} />,
action() {
setDialogOpen("QR", true);
},
},
{
label: t("contextual.command.qrImport"),
icon: <QrCodeIcon size={16} />,
action() {
setDialogOpen("import", true);
},
},
],
},
{
label: t("contextual.command.scheduleShutdown"),
icon: PowerIcon,
action() {
setDialogOpen("shutdown", true);
},
},
{
label: t("contextual.command.scheduleReboot"),
icon: RefreshCwIcon,
action() {
setDialogOpen("reboot", true);
},
},
{
label: t("contextual.command.rebootToOtaMode"),
icon: RefreshCwIcon,
action() {
setDialogOpen("rebootOTA", true);
},
},
{
label: t("contextual.command.resetNodeDb"),
icon: TrashIcon,
action() {
connection?.resetNodes();
},
},
{
label: t("contextual.command.disconnect"),
icon: CloudOff,
action() {
connection?.disconnect().catch((error) => {
console.error("Failed to disconnect:", error);
});
},
},
{
label: t("contextual.command.factoryResetDevice"),
icon: FactoryIcon,
action() {
connection?.factoryResetDevice();
},
},
{
label: t("contextual.command.factoryResetConfig"),
icon: FactoryIcon,
action() {
connection?.factoryResetConfig();
},
},
],
},
{
id: "debugGroup",
label: t("debug.label"),
icon: BugIcon,
commands: [
{
label: t("debug.command.reconfigure"),
icon: RefreshCwIcon,
action() {
void connection?.configure();
},
},
{
label: t("debug.command.clearAllStoredMessages"),
icon: EraserIcon,
action() {
setDialogOpen("deleteMessages", true);
},
},
],
},
];
const sortedGroups = [...groups].sort((a, b) => {
const aPinned = pinnedItems.includes(a.id) ? 1 : 0;
const bPinned = pinnedItems.includes(b.id) ? 1 : 0;
return bPinned - aPinned;
});
const sortedGroups = [...groups].sort((a, b) => {
const aPinned = pinnedItems.includes(a.id) ? 1 : 0;
const bPinned = pinnedItems.includes(b.id) ? 1 : 0;
return bPinned - aPinned;
});
useEffect(() => {
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setCommandPaletteOpen(true);
}
};
useEffect(() => {
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setCommandPaletteOpen(true);
}
};
globalThis.addEventListener("keydown", handleKeydown);
return () => globalThis.removeEventListener("keydown", handleKeydown);
}, [setCommandPaletteOpen]);
globalThis.addEventListener("keydown", handleKeydown);
return () => globalThis.removeEventListener("keydown", handleKeydown);
}, [setCommandPaletteOpen]);
return (
<CommandDialog
open={commandPaletteOpen}
onOpenChange={setCommandPaletteOpen}
>
<CommandInput placeholder={t("search.commandPalette")} />
<CommandList>
<CommandEmpty>{t("emptyState")}</CommandEmpty>
{sortedGroups.map((group) => (
<CommandGroup
key={group.label}
heading={
<div className="flex items-center justify-between">
<span>{group.label}</span>
<button
type="button"
onClick={() => togglePinnedItem(group.id)}
className={cn(
"transition-all duration-300 scale-100 cursor-pointer p-2 focus:*:data-label:opacity-100",
)}
aria-description={pinnedItems.includes(group.label)
? t("unpinGroup.label")
: t("pinGroup.label")}
>
<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"
/>
<Pin
size={16}
className={cn(
"transition-opacity",
pinnedItems.includes(group.id)
? "opacity-100 text-red-500"
: "opacity-40 hover:opacity-70",
)}
/>
</button>
</div>
}
>
{group.commands.map((command) => (
<div key={command.label}>
<CommandItem
onSelect={() => {
command.action?.();
setCommandPaletteOpen(false);
}}
>
<command.icon size={16} className="mr-2" />
{command.label}
</CommandItem>
{command.subItems?.map((subItem) => (
<SubItem
key={subItem.label}
label={subItem.label}
icon={subItem.icon}
action={subItem.action}
/>
))}
</div>
))}
</CommandGroup>
))}
</CommandList>
</CommandDialog>
);
return (
<CommandDialog
open={commandPaletteOpen}
onOpenChange={setCommandPaletteOpen}
>
<CommandInput placeholder={t("search.commandPalette")} />
<CommandList>
<CommandEmpty>{t("emptyState")}</CommandEmpty>
{sortedGroups.map((group) => (
<CommandGroup
key={group.label}
heading={
<div className="flex items-center justify-between">
<span>{group.label}</span>
<button
type="button"
onClick={() => togglePinnedItem(group.id)}
className={cn(
"transition-all duration-300 scale-100 cursor-pointer p-2 focus:*:data-label:opacity-100",
)}
>
<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"
/>
<Pin
size={16}
className={cn(
"transition-opacity",
pinnedItems.includes(group.id)
? "opacity-100 text-red-500"
: "opacity-40 hover:opacity-70",
)}
/>
</button>
</div>
}
>
{group.commands.map((command) => (
<div key={command.label}>
<CommandItem
onSelect={() => {
command.action?.();
setCommandPaletteOpen(false);
}}
>
<command.icon size={16} className="mr-2" />
{command.label}
</CommandItem>
{command.subItems?.map((subItem) => (
<SubItem
key={subItem.label}
label={subItem.label}
icon={subItem.icon}
action={subItem.action}
/>
))}
</div>
))}
</CommandGroup>
))}
</CommandList>
</CommandDialog>
);
};
const SubItem = ({
label,
icon,
action,
label,
icon,
action,
}: {
label: string;
icon: React.ReactNode;
action: () => void;
label: string;
icon: React.ReactNode;
action: () => void;
}) => {
const search = useCommandState((state) => state.search);
if (!search) return null;
const search = useCommandState((state) => state.search);
if (!search) {
return null;
}
return (
<CommandItem onSelect={action}>
{icon}
{label}
</CommandItem>
);
return (
<CommandItem onSelect={action}>
{icon}
{label}
</CommandItem>
);
};
+16 -21
View File
@@ -8,15 +8,16 @@ import {
Search as SearchIcon,
ZapIcon,
} from "lucide-react";
import BatteryStatus from "./BatteryStatus.tsx";
import { Subtle } from "./UI/Typography/Subtle.tsx";
import { Avatar } from "./UI/Avatar.tsx";
import type { DeviceMetrics } from "./types.ts";
import { Button } from "./UI/Button.tsx";
import React, { Fragment } from "react";
import type React from "react";
import { Fragment } from "react";
import { useTranslation } from "react-i18next";
import ThemeSwitcher from "./ThemeSwitcher.tsx";
import BatteryStatus from "./BatteryStatus.tsx";
import LanguageSwitcher from "./LanguageSwitcher.tsx";
import ThemeSwitcher from "./ThemeSwitcher.tsx";
import type { DeviceMetrics } from "./types.ts";
import { Avatar } from "./UI/Avatar.tsx";
import { Button } from "./UI/Button.tsx";
import { Subtle } from "./UI/Typography/Subtle.tsx";
interface DeviceInfoPanelProps {
isCollapsed: boolean;
@@ -70,9 +71,10 @@ export const DeviceInfoPanel = ({
id: "voltage",
label: t("batteryVoltage.title"),
icon: ZapIcon,
value: voltage !== undefined
? `${voltage?.toPrecision(3)} V`
: t("unknown.notAvailable", "N/A"),
value:
voltage !== undefined
? `${voltage?.toPrecision(3)} V`
: t("unknown.notAvailable", "N/A"),
},
{
id: "firmware",
@@ -135,8 +137,7 @@ export const DeviceInfoPanel = ({
</div>
{!isCollapsed && (
<div className="my-2 h-px bg-gray-200 dark:bg-gray-700 flex-shrink-0">
</div>
<div className="my-2 h-px bg-gray-200 dark:bg-gray-700 flex-shrink-0" />
)}
<div
@@ -151,10 +152,7 @@ export const DeviceInfoPanel = ({
{deviceInfoItems.map((item) => {
const IconComponent = item.icon;
return (
<div
key={item.id}
className="flex items-center gap-2.5 text-sm"
>
<div key={item.id} className="flex items-center gap-2.5 text-sm">
{IconComponent && (
<IconComponent
size={16}
@@ -173,8 +171,7 @@ export const DeviceInfoPanel = ({
</div>
{!isCollapsed && (
<div className="my-2 h-px bg-gray-200 dark:bg-gray-700 flex-shrink-0">
</div>
<div className="my-2 h-px bg-gray-200 dark:bg-gray-700 flex-shrink-0" />
)}
<div
@@ -190,9 +187,7 @@ export const DeviceInfoPanel = ({
const Icon = buttonItem.icon;
if (buttonItem.render) {
return (
<Fragment key={buttonItem.id}>
{buttonItem.render()}
</Fragment>
<Fragment key={buttonItem.id}>{buttonItem.render()}</Fragment>
);
}
return (
@@ -1,8 +1,8 @@
import { DeleteMessagesDialog } from "@components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx";
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
// Ensure the path is correct for import
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
import { DeleteMessagesDialog } from "@components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx";
vi.mock("@core/stores/messageStore", () => ({
useMessageStore: vi.fn(() => ({
@@ -26,9 +26,7 @@ describe("DeleteMessagesDialog", () => {
});
it("calls onOpenChange with false when the close button (X) is clicked", () => {
render(
<DeleteMessagesDialog open onOpenChange={mockOnOpenChange} />,
);
render(<DeleteMessagesDialog open onOpenChange={mockOnOpenChange} />);
const closeButton = screen.queryByTestId("dialog-close-button");
if (!closeButton) {
throw new Error(
@@ -41,15 +39,15 @@ describe("DeleteMessagesDialog", () => {
});
it("renders the dialog when open is true", () => {
render(
<DeleteMessagesDialog open onOpenChange={mockOnOpenChange} />,
);
render(<DeleteMessagesDialog open onOpenChange={mockOnOpenChange} />);
expect(screen.getByText("Clear All Messages")).toBeInTheDocument();
expect(screen.getByText(/This action will clear all message history./))
.toBeInTheDocument();
expect(
screen.getByText(/This action will clear all message history./),
).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Dismiss" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Clear Messages" }))
.toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Clear Messages" }),
).toBeInTheDocument();
});
it("does not render the dialog when open is false", () => {
@@ -60,18 +58,14 @@ describe("DeleteMessagesDialog", () => {
});
it("calls onOpenChange with false when the dismiss button is clicked", () => {
render(
<DeleteMessagesDialog open onOpenChange={mockOnOpenChange} />,
);
render(<DeleteMessagesDialog open onOpenChange={mockOnOpenChange} />);
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
expect(mockOnOpenChange).toHaveBeenCalledTimes(1); // Add count check
expect(mockOnOpenChange).toHaveBeenCalledWith(false);
});
it("calls deleteAllMessages and onOpenChange with false when the clear messages button is clicked", () => {
render(
<DeleteMessagesDialog open onOpenChange={mockOnOpenChange} />,
);
render(<DeleteMessagesDialog open onOpenChange={mockOnOpenChange} />);
fireEvent.click(screen.getByRole("button", { name: "Clear Messages" }));
expect(mockClearAllMessages).toHaveBeenCalledTimes(1);
expect(mockOnOpenChange).toHaveBeenCalledTimes(1); // Add count check
@@ -9,8 +9,8 @@ import {
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { AlertTriangleIcon } from "lucide-react";
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
import { useTranslation } from "react-i18next";
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
export interface DeleteMessagesDialogProps {
open: boolean;
@@ -41,11 +41,7 @@ export const DeleteMessagesDialog = ({
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-4">
<Button
variant="outline"
onClick={handleCloseDialog}
name="dismiss"
>
<Button variant="outline" onClick={handleCloseDialog} name="dismiss">
{t("button.dismiss")}
</Button>
<Button
@@ -1,5 +1,5 @@
import { useDevice } from "@core/stores/deviceStore.ts";
import { create } from "@bufbuild/protobuf";
import { GenericInput } from "@components/Form/FormInput.tsx";
import { Button } from "@components/UI/Button.tsx";
import {
Dialog,
@@ -10,13 +10,13 @@ import {
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { zodResolver } from "@hookform/resolvers/zod";
import { Protobuf } from "@meshtastic/core";
import { useForm } from "react-hook-form";
import { GenericInput } from "@components/Form/FormInput.tsx";
import { useTranslation } from "react-i18next";
import { Label } from "../UI/Label.tsx";
import z from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { Label } from "../UI/Label.tsx";
export interface User {
longName: string;
@@ -76,15 +76,11 @@ export const DeviceNameDialog = ({
<DialogClose />
<DialogHeader>
<DialogTitle>{t("deviceName.title")}</DialogTitle>
<DialogDescription>
{t("deviceName.description")}
</DialogDescription>
<DialogDescription>{t("deviceName.description")}</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit} className="flex flex-col gap-4">
<div>
<Label htmlFor="longName">
{t("deviceName.longName")}
</Label>
<Label htmlFor="longName">{t("deviceName.longName")}</Label>
<GenericInput
control={control}
field={{
@@ -104,9 +100,7 @@ export const DeviceNameDialog = ({
/>
</div>
<div>
<Label htmlFor="shortName">
{t("deviceName.shortName")}
</Label>
<Label htmlFor="shortName">{t("deviceName.shortName")}</Label>
<GenericInput
control={control}
field={{
@@ -134,10 +128,7 @@ export const DeviceNameDialog = ({
>
{t("button.reset")}
</Button>
<Button
type="submit"
name="save"
>
<Button type="submit" name="save">
{t("button.save")}
</Button>
</DialogFooter>
@@ -1,16 +1,16 @@
import { useDevice } from "@core/stores/deviceStore.ts";
import { RemoveNodeDialog } from "@components/Dialog/RemoveNodeDialog.tsx";
import { DeleteMessagesDialog } from "@components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx";
import { DeviceNameDialog } from "@components/Dialog/DeviceNameDialog.tsx";
import { ImportDialog } from "@components/Dialog/ImportDialog.tsx";
import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx";
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/NodeDetailsDialog.tsx";
import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx";
import { RefreshKeysDialog } from "@components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx";
import { RebootOTADialog } from "@components/Dialog/RebootOTADialog.tsx";
import { DeleteMessagesDialog } from "@components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx";
import { RefreshKeysDialog } from "@components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx";
import { RemoveNodeDialog } from "@components/Dialog/RemoveNodeDialog.tsx";
import { ShutdownDialog } from "@components/Dialog/ShutdownDialog.tsx";
import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
export const DialogManager = () => {
const { channels, config, dialog, setDialogOpen } = useDevice();
@@ -1,6 +1,5 @@
import { create, fromBinary } from "@bufbuild/protobuf";
import { Button } from "@components/UI/Button.tsx";
import { Checkbox } from "../UI/Checkbox/index.tsx";
import {
Dialog,
DialogClose,
@@ -18,6 +17,7 @@ import { Protobuf } from "@meshtastic/core";
import { toByteArray } from "base64-js";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Checkbox } from "../UI/Checkbox/index.tsx";
export interface ImportDialogProps {
open: boolean;
@@ -25,10 +25,7 @@ export interface ImportDialogProps {
loraConfig?: Protobuf.Config.Config_LoRaConfig;
}
export const ImportDialog = ({
open,
onOpenChange,
}: ImportDialogProps) => {
export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => {
const { t } = useTranslation("dialog");
const [importDialogInput, setImportDialogInput] = useState<string>("");
const [channelSet, setChannelSet] = useState<Protobuf.AppOnly.ChannelSet>();
@@ -69,7 +66,7 @@ export const ImportDialog = ({
setValidUrl(false);
setChannelSet(undefined);
}
}, [importDialogInput]);
}, [importDialogInput, t]);
const apply = () => {
channelSet?.settings.map(
@@ -77,9 +74,10 @@ export const ImportDialog = ({
connection?.setChannel(
create(Protobuf.Channel.ChannelSchema, {
index,
role: index === 0
? Protobuf.Channel.Channel_Role.PRIMARY
: Protobuf.Channel.Channel_Role.SECONDARY,
role:
index === 0
? Protobuf.Channel.Channel_Role.PRIMARY
: Protobuf.Channel.Channel_Role.SECONDARY,
settings: ch,
}),
);
@@ -104,9 +102,7 @@ export const ImportDialog = ({
<DialogClose />
<DialogHeader>
<DialogTitle>{t("import.title")}</DialogTitle>
<DialogDescription>
{t("import.description")}
</DialogDescription>
<DialogDescription>{t("import.description")}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
<Label>{t("import.channelSetUrl")}</Label>
@@ -127,25 +123,21 @@ export const ImportDialog = ({
checked={channelSet?.loraConfig?.usePreset ?? true}
/>
</div>
{
/* <Select
{/* <Select
label="Modem Preset"
disabled
value={channelSet?.loraConfig?.modemPreset}
>
{renderOptions(Protobuf.Config_LoRaConfig_ModemPreset)}
</Select> */
}
</Select> */}
</div>
{
/* <Select
{/* <Select
label="Region"
disabled
value={channelSet?.loraConfig?.region}
>
{renderOptions(Protobuf.Config_LoRaConfig_RegionCode)}
</Select> */
}
</Select> */}
<span className="text-md block font-medium text-text-primary">
{t("import.channels")}
@@ -1,4 +1,7 @@
import { useDevice } from "@core/stores/deviceStore.ts";
import type { Protobuf, Types } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { useTranslation } from "react-i18next";
import {
Dialog,
DialogClose,
@@ -7,9 +10,6 @@ import {
DialogHeader,
DialogTitle,
} from "../UI/Dialog.tsx";
import type { Protobuf, Types } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { useTranslation } from "react-i18next";
export interface LocationResponseDialogProps {
location: Types.PacketMetadata<Protobuf.Mesh.Position> | undefined;
@@ -26,16 +26,19 @@ export const LocationResponseDialog = ({
const { getNode } = useDevice();
const from = getNode(location?.from ?? 0);
const longName = from?.user?.longName ??
const longName =
from?.user?.longName ??
(from ? `!${numberToHexUnpadded(from?.num)}` : t("unknown.shortName"));
const shortName = from?.user?.shortName ??
const shortName =
from?.user?.shortName ??
(from
? `${numberToHexUnpadded(from?.num).substring(0, 4)}`
: t("unknown.shortName"));
const position = location?.data;
const hasCoordinates = position &&
const hasCoordinates =
position &&
typeof position.latitudeI === "number" &&
typeof position.longitudeI === "number" &&
typeof position.altitude === "number";
@@ -53,40 +56,38 @@ export const LocationResponseDialog = ({
</DialogTitle>
</DialogHeader>
<DialogDescription>
{hasCoordinates
? (
<div className="ml-5 flex">
<span className="ml-4 border-l-2 border-l-backgroundPrimary pl-2 text-textPrimary">
<p>
{t("locationResponse.coordinates")}
<a
className="text-blue-500 dark:text-blue-400"
href={`https://www.openstreetmap.org/?mlat=${
position.latitudeI ?? 0 / 1e7
}&mlon=${position.longitudeI ?? 0 / 1e7}&layers=N`}
target="_blank"
rel="noreferrer"
>
{" "}
{position.latitudeI ?? 0 / 1e7},{" "}
{position.longitudeI ?? 0 / 1e7}
</a>
</p>
<p>
{t("locationResponse.altitude")} {position.altitude}
{(position.altitude ?? 0) < 1
? t("unit.meter.one")
: t("unit.meter.plural")}
</p>
</span>
</div>
)
: (
// Optional: Show a message if coordinates are not available
<p className="text-textPrimary">
{t("locationResponse.noCoordinates")}
</p>
)}
{hasCoordinates ? (
<div className="ml-5 flex">
<span className="ml-4 border-l-2 border-l-backgroundPrimary pl-2 text-textPrimary">
<p>
{t("locationResponse.coordinates")}
<a
className="text-blue-500 dark:text-blue-400"
href={`https://www.openstreetmap.org/?mlat=${
position.latitudeI ?? 0 / 1e7
}&mlon=${position.longitudeI ?? 0 / 1e7}&layers=N`}
target="_blank"
rel="noreferrer"
>
{" "}
{position.latitudeI ?? 0 / 1e7},{" "}
{position.longitudeI ?? 0 / 1e7}
</a>
</p>
<p>
{t("locationResponse.altitude")} {position.altitude}
{(position.altitude ?? 0) < 1
? t("unit.meter.one")
: t("unit.meter.plural")}
</p>
</span>
</div>
) : (
// Optional: Show a message if coordinates are not available
<p className="text-textPrimary">
{t("locationResponse.noCoordinates")}
</p>
)}
</DialogDescription>
</DialogContent>
</Dialog>
@@ -1,4 +1,5 @@
import { Button } from "@components/UI/Button.tsx";
import { Checkbox } from "@components/UI/Checkbox/index.tsx";
import {
Dialog,
DialogClose,
@@ -8,9 +9,8 @@ import {
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { Trans, useTranslation } from "react-i18next";
import { Checkbox } from "@components/UI/Checkbox/index.tsx";
import { useState } from "react";
import { Trans, useTranslation } from "react-i18next";
export interface ManagedModeDialogProps {
open: boolean;
@@ -36,7 +36,7 @@ export const ManagedModeDialog = ({
<Trans
i18nKey="managedMode.description"
components={{
"bold": <p className="font-bold inline" />,
bold: <p className="font-bold inline" />,
}}
/>
</DialogDescription>
@@ -1,7 +1,3 @@
import {
type BrowserFeature,
useBrowserFeatureDetection,
} from "@core/hooks/useBrowserFeatureDetection.ts";
import { BLE } from "@components/PageComponents/Connect/BLE.tsx";
import { HTTP } from "@components/PageComponents/Connect/HTTP.tsx";
import { Serial } from "@components/PageComponents/Connect/Serial.tsx";
@@ -18,6 +14,10 @@ import {
TabsList,
TabsTrigger,
} from "@components/UI/Tabs.tsx";
import {
type BrowserFeature,
useBrowserFeatureDetection,
} from "@core/hooks/useBrowserFeatureDetection.ts";
import { AlertCircle } from "lucide-react";
import { Trans, useTranslation } from "react-i18next";
import { Link } from "../UI/Typography/Link.tsx";
@@ -45,24 +45,23 @@ interface FeatureErrorProps {
const errors: Record<BrowserFeature, { href: string; i18nKey: string }> = {
"Web Bluetooth": {
href:
"https://developer.mozilla.org/en-US/docs/Web/API/Web_Bluetooth_API#browser_compatibility",
href: "https://developer.mozilla.org/en-US/docs/Web/API/Web_Bluetooth_API#browser_compatibility",
i18nKey: "newDeviceDialog.validation.requiresWebBluetooth",
},
"Web Serial": {
href:
"https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API#browser_compatibility",
href: "https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API#browser_compatibility",
i18nKey: "newDeviceDialog.validation.requiresWebSerial",
},
"Secure Context": {
href:
"https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts",
href: "https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts",
i18nKey: "newDeviceDialog.validation.requiresSecureContext",
},
};
const ErrorMessage = ({ missingFeatures, tabId }: FeatureErrorProps) => {
if (missingFeatures.length === 0) return null;
if (missingFeatures.length === 0) {
return null;
}
const browserFeatures = missingFeatures.filter(
(feature) => feature !== "Secure Context",
@@ -70,11 +69,11 @@ const ErrorMessage = ({ missingFeatures, tabId }: FeatureErrorProps) => {
const needsSecureContext = missingFeatures.includes("Secure Context");
const needsFeature =
(tabId === "BLE" && browserFeatures.includes("Web Bluetooth"))
tabId === "BLE" && browserFeatures.includes("Web Bluetooth")
? "Web Bluetooth"
: (tabId === "Serial" && browserFeatures.includes("Web Serial"))
? "Web Serial"
: undefined;
: tabId === "Serial" && browserFeatures.includes("Web Serial")
? "Web Serial"
: undefined;
return (
<div className="flex flex-col items-start gap-2 bg-red-500 p-4 rounded-md text-sm text-slate-500 dark:text-slate-400">
@@ -97,9 +96,11 @@ const ErrorMessage = ({ missingFeatures, tabId }: FeatureErrorProps) => {
{needsFeature && needsSecureContext && " "}
{needsSecureContext && (
<Trans
i18nKey={browserFeatures.length > 0
? "newDeviceDialog.validation.additionallyRequiresSecureContext"
: "newDeviceDialog.validation.requiresSecureContext"}
i18nKey={
browserFeatures.length > 0
? "newDeviceDialog.validation.additionallyRequiresSecureContext"
: "newDeviceDialog.validation.requiresSecureContext"
}
components={{
"0": (
<Link
@@ -117,10 +118,7 @@ const ErrorMessage = ({ missingFeatures, tabId }: FeatureErrorProps) => {
);
};
export const NewDeviceDialog = ({
open,
onOpenChange,
}: NewDeviceProps) => {
export const NewDeviceDialog = ({ open, onOpenChange }: NewDeviceProps) => {
const { t } = useTranslation("dialog");
const { unsupported } = useBrowserFeatureDetection();
@@ -135,14 +133,16 @@ export const NewDeviceDialog = ({
id: "BLE",
label: t("newDeviceDialog.tabBluetooth"),
element: BLE,
isDisabled: unsupported.includes("Web Bluetooth") ||
isDisabled:
unsupported.includes("Web Bluetooth") ||
unsupported.includes("Secure Context"),
},
{
id: "Serial",
label: t("newDeviceDialog.tabSerial"),
element: Serial,
isDisabled: unsupported.includes("Web Serial") ||
isDisabled:
unsupported.includes("Web Serial") ||
unsupported.includes("Secure Context"),
},
];
@@ -165,19 +165,11 @@ export const NewDeviceDialog = ({
{tabs.map((tab) => (
<TabsContent key={tab.id} value={tab.id}>
<fieldset disabled={tab.isDisabled}>
{(tab.id !== "HTTP" &&
tab.isDisabled)
? (
<ErrorMessage
missingFeatures={unsupported}
tabId={tab.id}
/>
)
: (
<tab.element
closeDialog={() => onOpenChange(false)}
/>
)}
{tab.id !== "HTTP" && tab.isDisabled ? (
<ErrorMessage missingFeatures={unsupported} tabId={tab.id} />
) : (
<tab.element closeDialog={() => onOpenChange(false)} />
)}
</fieldset>
</TabsContent>
))}
@@ -1,22 +1,13 @@
import { useEffect, useState } from "react";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDevice } from "@core/stores/deviceStore.ts";
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";
import { toast } from "@core/hooks/useToast.ts";
import { useFavoriteNode } from "@core/hooks/useFavoriteNode.ts";
import { useIgnoreNode } from "@core/hooks/useIgnoreNode.ts";
import { cn } from "@core/utils/cn.ts";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@components/UI/Accordion.tsx";
import { Button } from "@components/UI/Button.tsx";
import {
Dialog,
DialogClose,
@@ -25,7 +16,23 @@ import {
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { Button } from "@components/UI/Button.tsx";
import { Separator } from "@components/UI/Seperator.tsx";
import {
Tooltip,
TooltipArrow,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@components/UI/Tooltip.tsx";
import { useFavoriteNode } from "@core/hooks/useFavoriteNode.ts";
import { useIgnoreNode } from "@core/hooks/useIgnoreNode.ts";
import { toast } from "@core/hooks/useToast.ts";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDevice } from "@core/stores/deviceStore.ts";
import { cn } from "@core/utils/cn.ts";
import { Protobuf } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { useNavigate } from "@tanstack/react-router";
import {
BellIcon,
BellOffIcon,
@@ -35,16 +42,8 @@ import {
TrashIcon,
WaypointsIcon,
} from "lucide-react";
import {
Tooltip,
TooltipArrow,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@components/UI/Tooltip.tsx";
import { Separator } from "@components/UI/Seperator.tsx";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "@tanstack/react-router";
export interface NodeDetailsDialogProps {
open: boolean;
@@ -72,21 +71,29 @@ export const NodeDetailsDialog = ({
);
useEffect(() => {
if (!node) return;
if (!node) {
return;
}
setIsFavoriteState(node?.isFavorite);
setIsIgnoredState(node?.isIgnored);
}, [node]);
if (!node) return;
if (!node) {
return;
}
function handleDirectMessage() {
if (!node) return;
if (!node) {
return;
}
navigate({ to: `/messages/direct/${node.num}` });
setDialogOpen("nodeDetails", false);
}
function handleRequestPosition() {
if (!node) return;
if (!node) {
return;
}
toast({
title: t("toast.requestingPosition.title", { ns: "ui" }),
@@ -94,13 +101,15 @@ export const NodeDetailsDialog = ({
connection?.requestPosition(node.num).then(() =>
toast({
title: t("toast.positionRequestSent.title", { ns: "ui" }),
})
}),
);
onOpenChange(false);
}
function handleTraceroute() {
if (!node) return;
if (!node) {
return;
}
toast({
title: t("toast.sendingTraceroute.title", { ns: "ui" }),
@@ -108,13 +117,15 @@ export const NodeDetailsDialog = ({
connection?.traceRoute(node.num).then(() =>
toast({
title: t("toast.tracerouteSent.title", { ns: "ui" }),
})
}),
);
onOpenChange(false);
}
function handleNodeRemove() {
if (!node) return;
if (!node) {
return;
}
setNodeNumToBeRemoved(node?.num);
setDialogOpen("nodeRemoval", true);
@@ -122,14 +133,18 @@ export const NodeDetailsDialog = ({
}
function handleToggleFavorite() {
if (!node) return;
if (!node) {
return;
}
updateFavorite({ nodeNum: node.num, isFavorite: !isFavoriteState });
setIsFavoriteState(!isFavoriteState);
}
function handleToggleIgnored() {
if (!node) return;
if (!node) {
return;
}
updateIgnored({ nodeNum: node.num, isIgnored: !isIgnoredState });
setIsIgnoredState(!isIgnoredState);
@@ -202,7 +217,7 @@ export const NodeDetailsDialog = ({
)}
/>
</Button>
<div className="flex flex-1 justify-start"></div>
<div className="flex flex-1 justify-start" />
<TooltipProvider delayDuration={300}>
<Tooltip>
@@ -255,7 +270,10 @@ export const NodeDetailsDialog = ({
<p className="text-lg font-semibold">
{t("nodeDetails.details")}
</p>
<p>{t("nodeDetails.nodeNumber")}{node.num}</p>
<p>
{t("nodeDetails.nodeNumber")}
{node.num}
</p>
<p>
{t("nodeDetails.nodeHexPrefix")}
{numberToHexUnpadded(node.num)}
@@ -268,21 +286,25 @@ export const NodeDetailsDialog = ({
</p>
<p>
{t("nodeDetails.lastHeard")}
{node.lastHeard === 0
? t("nodesTable.lastHeardStatus.never", { ns: "nodes" })
: <TimeAgo timestamp={node.lastHeard * 1000} />}
{node.lastHeard === 0 ? (
t("nodesTable.lastHeardStatus.never", { ns: "nodes" })
) : (
<TimeAgo timestamp={node.lastHeard * 1000} />
)}
</p>
<p>
{t("nodeDetails.hardware")}
{(Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0] ??
t("unknown.shortName"))
.replace(/_/g, " ")}
{(
Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0] ??
t("unknown.shortName")
).replace(/_/g, " ")}
</p>
</div>
<DeviceImage
className="h-45 w-45 p-2 rounded-lg border-4 border-slate-200 dark:border-slate-800"
deviceType={Protobuf.Mesh
.HardwareModel[node.user?.hwModel ?? 0]}
deviceType={
Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]
}
/>
</div>
</div>
@@ -293,36 +315,35 @@ export const NodeDetailsDialog = ({
{t("nodeDetails.position")}
</p>
{node.position
? (
<>
{node.position.latitudeI &&
node.position.longitudeI && (
<p>
{t("locationResponse.coordinates")}
<a
className="text-blue-500 dark:text-blue-400"
href={`https://www.openstreetmap.org/?mlat=${
node.position.latitudeI / 1e7
}&mlon=${node.position.longitudeI / 1e7}&layers=N`}
target="_blank"
rel="noreferrer"
>
{node.position.latitudeI / 1e7},{" "}
{node.position.longitudeI / 1e7}
</a>
</p>
)}
{node.position.altitude && (
<p>
{t("locationResponse.altitude")}
{node.position.altitude}
{t("unit.meter.one")}
</p>
)}
</>
)
: <p>{t("unknown.shortName")}</p>}
{node.position ? (
<>
{node.position.latitudeI && node.position.longitudeI && (
<p>
{t("locationResponse.coordinates")}
<a
className="text-blue-500 dark:text-blue-400"
href={`https://www.openstreetmap.org/?mlat=${
node.position.latitudeI / 1e7
}&mlon=${node.position.longitudeI / 1e7}&layers=N`}
target="_blank"
rel="noreferrer"
>
{node.position.latitudeI / 1e7},{" "}
{node.position.longitudeI / 1e7}
</a>
</p>
)}
{node.position.altitude && (
<p>
{t("locationResponse.altitude")}
{node.position.altitude}
{t("unit.meter.one")}
</p>
)}
</>
) : (
<p>{t("unknown.shortName")}</p>
)}
<Button
onClick={handleRequestPosition}
name="requestPosition"
@@ -338,14 +359,13 @@ export const NodeDetailsDialog = ({
<p className="text-lg font-semibold text-slate-900 dark:text-slate-100">
{t("nodeDetails.deviceMetrics")}
</p>
{deviceMetricsMap.map(
(metric) =>
metric.value !== undefined && (
<p key={metric.key}>
{metric.label}: {metric.format(metric.value)}
</p>
),
)}
{deviceMetricsMap
.filter((metric) => metric.value !== undefined)
.map((metric) => (
<p key={metric.key}>
{metric.label}: {metric.format(metric?.value ?? 0)}
</p>
))}
{node.deviceMetrics.uptimeSeconds && (
<p>
{t("nodeDetails.uptime")}
@@ -1,5 +1,3 @@
import { useDevice } from "../../core/stores/deviceStore.ts";
import { Button } from "../UI/Button.tsx";
import {
Dialog,
DialogClose,
@@ -13,6 +11,8 @@ import { fromByteArray } from "base64-js";
import { DownloadIcon, PrinterIcon } from "lucide-react";
import React from "react";
import { useTranslation } from "react-i18next";
import { useDevice } from "../../core/stores/deviceStore.ts";
import { Button } from "../UI/Button.tsx";
export interface PkiBackupDialogProps {
open: boolean;
@@ -30,7 +30,9 @@ export const PkiBackupDialog = ({
const decodeKeyData = React.useCallback(
(key: Uint8Array<ArrayBufferLike>) => {
if (!key) return "";
if (!key) {
return "";
}
return fromByteArray(key ?? new Uint8Array(0));
},
[],
@@ -41,20 +43,20 @@ export const PkiBackupDialog = ({
}, [setDialogOpen]);
const renderPrintWindow = React.useCallback(() => {
if (!privateKey || !publicKey) return;
if (!privateKey || !publicKey) {
return;
}
const printWindow = globalThis.open("", "_blank");
if (printWindow) {
printWindow.document.write(`
<html>
<head>
<title>${
t("pkiBackup.header", {
interpolation: { escapeValue: false },
shortName: getMyNode()?.user?.shortName ?? t("unknown.shortName"),
longName: getMyNode()?.user?.longName ?? t("unknown.longName"),
})
}</title>
<title>${t("pkiBackup.header", {
interpolation: { escapeValue: false },
shortName: getMyNode()?.user?.shortName ?? t("unknown.shortName"),
longName: getMyNode()?.user?.longName ?? t("unknown.longName"),
})}</title>
<style>
body { font-family: Arial, sans-serif; padding: 20px; }
h1 { font-size: 18px; }
@@ -62,13 +64,11 @@ export const PkiBackupDialog = ({
</style>
</head>
<body>
<h1>${
t("pkiBackup.header", {
interpolation: { escapeValue: false },
shortName: getMyNode()?.user?.shortName ?? t("unknown.shortName"),
longName: getMyNode()?.user?.longName ?? t("unknown.longName"),
})
}</h1>
<h1>${t("pkiBackup.header", {
interpolation: { escapeValue: false },
shortName: getMyNode()?.user?.shortName ?? t("unknown.shortName"),
longName: getMyNode()?.user?.longName ?? t("unknown.longName"),
})}</h1>
<h3>${t("pkiBackup.secureBackup")}</h3>
<h3>${t("pkiBackup.publicKey")}</h3>
<p>${decodeKeyData(publicKey)}</p>
@@ -82,10 +82,12 @@ export const PkiBackupDialog = ({
printWindow.print();
closeDialog();
}
}, [decodeKeyData, privateKey, publicKey, closeDialog, t]);
}, [decodeKeyData, privateKey, publicKey, closeDialog, t, getMyNode]);
const createDownloadKeyFile = React.useCallback(() => {
if (!privateKey || !publicKey) return;
if (!privateKey || !publicKey) {
return;
}
const decodedPrivateKey = decodeKeyData(privateKey);
const decodedPublicKey = decodeKeyData(publicKey);
@@ -116,7 +118,7 @@ export const PkiBackupDialog = ({
document.body.removeChild(link);
closeDialog();
URL.revokeObjectURL(url);
}, [decodeKeyData, privateKey, publicKey, closeDialog, t]);
}, [decodeKeyData, privateKey, publicKey, closeDialog, t, getMyNode]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -124,9 +126,7 @@ export const PkiBackupDialog = ({
<DialogClose />
<DialogHeader>
<DialogTitle>{t("pkiBackup.title")}</DialogTitle>
<DialogDescription>
{t("pkiBackup.secureBackup")}
</DialogDescription>
<DialogDescription>{t("pkiBackup.secureBackup")}</DialogDescription>
<DialogDescription>
<span className="font-bold break-before-auto">
{t("pkiBackup.loseKeysWarning")}
@@ -34,8 +34,7 @@ export const PkiRegenerateDialog = ({
const { t } = useTranslation("dialog");
const dialogText = {
title: text.title || t("pkiRegenerate.title"),
description: text.description ||
t("pkiRegenerate.description"),
description: text.description || t("pkiRegenerate.description"),
button: text.button || t("button.regenerate"),
};
return (
@@ -44,9 +43,7 @@ export const PkiRegenerateDialog = ({
<DialogClose />
<DialogHeader>
<DialogTitle>{dialogText.title}</DialogTitle>
<DialogDescription>
{dialogText.description}
</DialogDescription>
<DialogDescription>{dialogText.description}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
+10 -19
View File
@@ -1,5 +1,4 @@
import { create, toBinary } from "@bufbuild/protobuf";
import { Checkbox } from "../UI/Checkbox/index.tsx";
import {
Dialog,
DialogClose,
@@ -14,8 +13,9 @@ import { Label } from "@components/UI/Label.tsx";
import { Protobuf, type Types } from "@meshtastic/core";
import { fromByteArray } from "base64-js";
import { useEffect, useMemo, useState } from "react";
import { QRCode } from "react-qrcode-logo";
import { useTranslation } from "react-i18next";
import { QRCode } from "react-qrcode-logo";
import { Checkbox } from "../UI/Checkbox/index.tsx";
export interface QRDialogProps {
open: boolean;
@@ -67,9 +67,7 @@ export const QRDialog = ({
<DialogClose />
<DialogHeader>
<DialogTitle>{t("qr.title")}</DialogTitle>
<DialogDescription>
{t("qr.description")}
</DialogDescription>
<DialogDescription>{t("qr.description")}</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="flex gap-3 px-4 py-5 sm:p-6">
@@ -80,13 +78,11 @@ export const QRDialog = ({
{channel.settings?.name.length
? channel.settings.name
: channel.role === Protobuf.Channel.Channel_Role.PRIMARY
? t("page.broadcastLabel", { ns: "channels" })
: `${
t("page.channelIndex", {
ns: "channels",
index: channel.index,
})
}${channel.index}`}
? t("page.broadcastLabel", { ns: "channels" })
: `${t("page.channelIndex", {
ns: "channels",
index: channel.index,
})}${channel.index}`}
</Label>
<Checkbox
key={channel.index}
@@ -94,9 +90,7 @@ export const QRDialog = ({
onChange={() => {
if (selectedChannels.includes(channel.index)) {
setSelectedChannels(
selectedChannels.filter((c) =>
c !== channel.index
),
selectedChannels.filter((c) => c !== channel.index),
);
} else {
setSelectedChannels([
@@ -140,10 +134,7 @@ export const QRDialog = ({
</div>
<DialogFooter>
<Label>{t("qr.sharableUrl")}</Label>
<Input
value={qrCodeUrl}
disabled
/>
<Input value={qrCodeUrl} disabled />
</DialogFooter>
</DialogContent>
</Dialog>
@@ -10,18 +10,15 @@ import {
import { Input } from "@components/UI/Input.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { RefreshCwIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useState } from "react";
import { useTranslation } from "react-i18next";
export interface RebootDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const RebootDialog = ({
open,
onOpenChange,
}: RebootDialogProps) => {
export const RebootDialog = ({ open, onOpenChange }: RebootDialogProps) => {
const { t } = useTranslation("dialog");
const { connection } = useDevice();
@@ -32,12 +29,8 @@ export const RebootDialog = ({
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>
{t("reboot.title")}
</DialogTitle>
<DialogDescription>
{t("reboot.description")}
</DialogDescription>
<DialogTitle>{t("reboot.title")}</DialogTitle>
<DialogDescription>{t("reboot.description")}</DialogDescription>
</DialogHeader>
<div className="flex gap-2 p-4">
<Input
@@ -1,13 +1,13 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { RebootOTADialog } from "./RebootOTADialog.tsx";
import {
import type {
ButtonHTMLAttributes,
ClassAttributes,
InputHTMLAttributes,
ReactNode,
} from "react";
import { JSX } from "react/jsx-runtime";
import type { JSX } from "react/jsx-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { RebootOTADialog } from "./RebootOTADialog.tsx";
const rebootOtaMock = vi.fn();
let mockConnection: { rebootOta: (delay: number) => void } | undefined = {
@@ -25,10 +25,9 @@ vi.mock("@components/UI/Button.tsx", async () => {
return {
...actual,
Button: (
props:
& JSX.IntrinsicAttributes
& ClassAttributes<HTMLButtonElement>
& ButtonHTMLAttributes<HTMLButtonElement>,
props: JSX.IntrinsicAttributes &
ClassAttributes<HTMLButtonElement> &
ButtonHTMLAttributes<HTMLButtonElement>,
) => <button {...props} />,
};
});
@@ -38,10 +37,9 @@ vi.mock("@components/UI/Input.tsx", async () => {
return {
...actual,
Input: (
props:
& JSX.IntrinsicAttributes
& ClassAttributes<HTMLInputElement>
& InputHTMLAttributes<HTMLInputElement>,
props: JSX.IntrinsicAttributes &
ClassAttributes<HTMLInputElement> &
InputHTMLAttributes<HTMLInputElement>,
) => <input {...props} />,
};
});
@@ -76,8 +74,9 @@ describe("RebootOTADialog", () => {
it("renders dialog with default input value", () => {
render(<RebootOTADialog open onOpenChange={() => {}} />);
expect(screen.getByPlaceholderText(/enter delay/i)).toHaveValue(5);
expect(screen.getByRole("heading", { name: /schedule reboot/i, level: 1 }))
.toBeInTheDocument();
expect(
screen.getByRole("heading", { name: /schedule reboot/i, level: 1 }),
).toBeInTheDocument();
expect(screen.getByText(/reboot to ota mode now/i)).toBeInTheDocument();
});
@@ -1,5 +1,3 @@
import { useState } from "react";
import { ClockIcon, RefreshCwIcon } from "lucide-react";
import { Button } from "@components/UI/Button.tsx";
import {
Dialog,
@@ -11,6 +9,8 @@ import {
} from "@components/UI/Dialog.tsx";
import { Input } from "@components/UI/Input.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { ClockIcon, RefreshCwIcon } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
export interface RebootOTADialogProps {
@@ -20,9 +20,10 @@ export interface RebootOTADialogProps {
const DEFAULT_REBOOT_DELAY = 5; // seconds
export const RebootOTADialog = (
{ open, onOpenChange }: RebootOTADialogProps,
) => {
export const RebootOTADialog = ({
open,
onOpenChange,
}: RebootOTADialogProps) => {
const { t } = useTranslation("dialog");
const { connection } = useDevice();
const [time, setTime] = useState<number>(DEFAULT_REBOOT_DELAY);
@@ -39,13 +40,15 @@ export const RebootOTADialog = (
setInputValue(val);
const parsed = Number(val);
if (!isNaN(parsed) && parsed > 0) {
if (!Number.isNaN(parsed) && parsed > 0) {
setTime(parsed);
}
};
const handleRebootWithTimeout = async () => {
if (!connection) return;
if (!connection) {
return;
}
setIsScheduled(true);
const delay = time > 0 ? time : DEFAULT_REBOOT_DELAY;
@@ -63,7 +66,9 @@ export const RebootOTADialog = (
};
const handleInstantReboot = async () => {
if (!connection) return;
if (!connection) {
return;
}
await connection.rebootOta(DEFAULT_REBOOT_DELAY);
onOpenChange(false);
@@ -74,12 +79,8 @@ export const RebootOTADialog = (
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>
{t("rebootOta.title")}
</DialogTitle>
<DialogDescription>
{t("rebootOta.description")}
</DialogDescription>
<DialogTitle>{t("rebootOta.title")}</DialogTitle>
<DialogDescription>{t("rebootOta.description")}</DialogDescription>
</DialogHeader>
<div className="flex gap-2 p-2 items-center relative">
@@ -1,9 +1,9 @@
import { render } 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 { render } from "@testing-library/react";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
import { RefreshKeysDialog } from "./RefreshKeysDialog.tsx";
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
vi.mock("@core/stores/messageStore");
vi.mock("./useRefreshKeysDialog");
@@ -12,8 +12,10 @@ const mockUseMessageStore = vi.mocked(useMessageStore);
const mockUseRefreshKeysDialog = vi.mocked(useRefreshKeysDialog);
const getInitialState = () =>
useDeviceStore.getInitialState?.() ??
{ devices: new Map(), remoteDevices: new Map() };
useDeviceStore.getInitialState?.() ?? {
devices: new Map(),
remoteDevices: new Map(),
};
beforeEach(() => {
useDeviceStore.setState(getInitialState(), true);
@@ -31,7 +33,9 @@ test("does not render dialog if no error exists for active chat", () => {
useDeviceStore.getState().addDevice(deviceId);
const currentDeviceState = useDeviceStore.getState().getDevice(deviceId);
if (!currentDeviceState) throw new Error("Device not found");
if (!currentDeviceState) {
throw new Error("Device not found");
}
mockUseMessageStore.mockReturnValue({ activeChat: activeChatNum });
mockUseRefreshKeysDialog.mockReturnValue({
@@ -1,3 +1,4 @@
import { Button } from "@components/UI/Button.tsx";
import {
Dialog,
DialogClose,
@@ -5,21 +6,21 @@ import {
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
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 { useTranslation } from "react-i18next";
import { useMessageStore } from "@core/stores/messageStore/index.ts";
import { LockKeyholeOpenIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
export interface RefreshKeysDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const RefreshKeysDialog = (
{ open, onOpenChange }: RefreshKeysDialogProps,
) => {
export const RefreshKeysDialog = ({
open,
onOpenChange,
}: RefreshKeysDialogProps) => {
const { t } = useTranslation("dialog");
const { activeChat } = useMessageStore();
const { nodeErrors, getNode } = useDevice();
@@ -40,9 +41,9 @@ export const RefreshKeysDialog = (
}),
description: `${t("refreshKeys.description.unableToSendDmPrefix")}${
nodeWithError?.user?.longName ?? ""
} (${nodeWithError?.user?.shortName ?? ""})${
t("refreshKeys.description.keyMismatchReasonSuffix")
}`,
} (${nodeWithError?.user?.shortName ?? ""})${t(
"refreshKeys.description.keyMismatchReasonSuffix",
)}`,
};
return (
@@ -69,9 +70,7 @@ export const RefreshKeysDialog = (
<p className="font-bold mb-0.5">
{t("refreshKeys.label.acceptNewKeys")}
</p>
<p>
{t("refreshKeys.description.acceptNewKeys")}
</p>
<p>{t("refreshKeys.description.acceptNewKeys")}</p>
</div>
<Button
variant="default"
@@ -1,6 +1,6 @@
import { useCallback } from "react";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useMessageStore } from "@core/stores/messageStore/index.ts";
import { useCallback } from "react";
export function useRefreshKeysDialog() {
const { removeNode, setDialogOpen, clearNodeError, getNodeError } =
@@ -1,5 +1,3 @@
import { useAppStore } from "../../core/stores/appStore.ts";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Button } from "@components/UI/Button.tsx";
import {
Dialog,
@@ -11,7 +9,9 @@ import {
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { Label } from "@components/UI/Label.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useTranslation } from "react-i18next";
import { useAppStore } from "../../core/stores/appStore.ts";
export interface RemoveNodeDialogProps {
open: boolean;
@@ -38,9 +38,7 @@ export const RemoveNodeDialog = ({
<DialogClose />
<DialogHeader>
<DialogTitle>{t("removeNode.title")}</DialogTitle>
<DialogDescription>
{t("removeNode.description")}
</DialogDescription>
<DialogDescription>{t("removeNode.description")}</DialogDescription>
</DialogHeader>
<div className="gap-4">
<form onSubmit={onSubmit}>
@@ -10,18 +10,15 @@ import {
import { Input } from "@components/UI/Input.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { ClockIcon, PowerIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useState } from "react";
import { useTranslation } from "react-i18next";
export interface ShutdownDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const ShutdownDialog = ({
open,
onOpenChange,
}: ShutdownDialogProps) => {
export const ShutdownDialog = ({ open, onOpenChange }: ShutdownDialogProps) => {
const { t } = useTranslation("dialog");
const { connection } = useDevice();
@@ -32,12 +29,8 @@ export const ShutdownDialog = ({
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>
{t("shutdown.title")}
</DialogTitle>
<DialogDescription>
{t("shutdown.description")}
</DialogDescription>
<DialogTitle>{t("shutdown.title")}</DialogTitle>
<DialogDescription>{t("shutdown.description")}</DialogDescription>
</DialogHeader>
<div className="flex gap-2 p-4">
@@ -1,4 +1,9 @@
import { useDevice } from "@core/stores/deviceStore.ts";
import type { Protobuf, Types } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { useTranslation } from "react-i18next";
import { TraceRoute } from "../PageComponents/Messages/TraceRoute.tsx";
import {
Dialog,
DialogClose,
@@ -7,11 +12,6 @@ import {
DialogHeader,
DialogTitle,
} from "../UI/Dialog.tsx";
import type { Protobuf, Types } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { TraceRoute } from "../PageComponents/Messages/TraceRoute.tsx";
import { useTranslation } from "react-i18next";
export interface TracerouteResponseDialogProps {
traceroute: Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery> | undefined;
@@ -31,9 +31,11 @@ export const TracerouteResponseDialog = ({
const snrTowards = (traceroute?.data.snrTowards ?? []).map((snr) => snr / 4);
const snrBack = (traceroute?.data.snrBack ?? []).map((snr) => snr / 4);
const from = getNode(traceroute?.from ?? 0);
const fromLongName = from?.user?.longName ??
const fromLongName =
from?.user?.longName ??
(from ? `!${numberToHexUnpadded(from?.num)}` : t("unknown.shortName"));
const fromShortName = from?.user?.shortName ??
const fromShortName =
from?.user?.shortName ??
(from
? `${numberToHexUnpadded(from?.num).substring(0, 4)}`
: t("unknown.shortName"));
@@ -1,3 +1,5 @@
import { Button } from "@components/UI/Button.tsx";
import { Checkbox } from "@components/UI/Checkbox/index.tsx";
import {
Dialog,
DialogClose,
@@ -8,11 +10,9 @@ import {
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { Link } from "@components/UI/Typography/Link.tsx";
import { Checkbox } from "@components/UI/Checkbox/index.tsx";
import { Button } from "@components/UI/Button.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useState } from "react";
import { eventBus } from "@core/utils/eventBus.ts";
import { useState } from "react";
import { useTranslation } from "react-i18next";
export interface RouterRoleDialogProps {
@@ -20,9 +20,10 @@ export interface RouterRoleDialogProps {
onOpenChange: (open: boolean) => void;
}
export const UnsafeRolesDialog = (
{ open, onOpenChange }: RouterRoleDialogProps,
) => {
export const UnsafeRolesDialog = ({
open,
onOpenChange,
}: RouterRoleDialogProps) => {
const { t } = useTranslation("dialog");
const [confirmState, setConfirmState] = useState(false);
const { setDialogOpen } = useDevice();
@@ -1,3 +1,9 @@
import {
UNSAFE_ROLES,
useUnsafeRolesDialog,
} from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
import { eventBus } from "@core/utils/eventBus.ts";
import { renderHook } from "@testing-library/react";
import {
afterEach,
beforeEach,
@@ -7,18 +13,11 @@ import {
type Mock,
vi,
} from "vitest";
import { renderHook } from "@testing-library/react";
import {
UNSAFE_ROLES,
useUnsafeRolesDialog,
} from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
import { eventBus } from "@core/utils/eventBus.ts";
const mockNavigate = vi.fn();
vi.mock("@tanstack/react-router", async (importOriginal) => {
const actual = await importOriginal<
typeof import("@tanstack/react-router")
>();
const actual =
await importOriginal<typeof import("@tanstack/react-router")>();
return {
...actual,
useNavigate: () => mockNavigate,
@@ -75,9 +74,8 @@ describe("useUnsafeRolesDialog", () => {
const { result } = renderUnsafeRolesHook();
const safeRole = "SAFE_ROLE";
const validationResult = await result.current.validateRoleSelection(
safeRole,
);
const validationResult =
await result.current.validateRoleSelection(safeRole);
expect(validationResult).toBe(true);
expect(mockDevice.setDialogOpen).not.toHaveBeenCalled();
@@ -151,9 +149,8 @@ describe("useUnsafeRolesDialog", () => {
mockDevice.setDialogOpen.mockClear();
(eventBus.on as Mock).mockClear();
const validationPromise = result.current.validateRoleSelection(
unsafeRole,
);
const validationPromise =
result.current.validateRoleSelection(unsafeRole);
expect(mockDevice.setDialogOpen).toHaveBeenCalledWith(
"unsafeRoles",
@@ -1,9 +1,9 @@
import { useCallback } from "react";
import { eventBus } from "@core/utils/eventBus.ts";
import { useDevice } from "@core/stores/deviceStore.ts";
import { eventBus } from "@core/utils/eventBus.ts";
import { useCallback } from "react";
export const UNSAFE_ROLES = ["ROUTER", "ROUTER_LATE", "REPEATER"];
export type UnsafeRole = typeof UNSAFE_ROLES[number];
export type UnsafeRole = (typeof UNSAFE_ROLES)[number];
export const useUnsafeRolesDialog = () => {
const { setDialogOpen } = useDevice();
@@ -21,9 +21,11 @@ export const useUnsafeRolesDialog = () => {
setDialogOpen("unsafeRoles", true);
return new Promise((resolve) => {
const handleResponse = (
{ action }: { action: "confirm" | "dismiss" },
) => {
const handleResponse = ({
action,
}: {
action: "confirm" | "dismiss";
}) => {
eventBus.off("dialog:unsafeRoles", handleResponse);
resolve(action === "confirm");
};
@@ -1,10 +1,15 @@
import { createZodResolver } from "@components/Form/createZodResolver.ts";
import {
DynamicFormField,
type FieldProps,
} from "@components/Form/DynamicFormField.tsx";
import { FieldWrapper } from "@components/Form/FormWrapper.tsx";
import { Button } from "@components/UI/Button.tsx";
import { Heading } from "@components/UI/Typography/Heading.tsx";
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
import { useAppStore } from "@core/stores/appStore.ts";
import { dotPaths } from "@core/utils/dotPath.ts";
import { useEffect } from "react";
import {
type Control,
type DefaultValues,
@@ -13,16 +18,11 @@ import {
get,
type Path,
type SubmitHandler,
useForm,
type UseFormReturn,
useForm,
} from "react-hook-form";
import { Heading } from "@components/UI/Typography/Heading.tsx";
import { ZodType } from "zod/v4";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { createZodResolver } from "@components/Form/createZodResolver.ts";
import { useAppStore } from "@core/stores/appStore.ts";
import { dotPaths } from "@core/utils/dotPath.ts";
import type { ZodType } from "zod/v4";
interface DisabledBy<T> {
fieldName: Path<T>;
@@ -86,41 +86,34 @@ export function DynamicForm<T extends FieldValues>({
formId,
}: DynamicFormProps<T>) {
const { t } = useTranslation();
const {
addError,
removeError,
} = useAppStore();
const { addError, removeError } = useAppStore();
const internalMethods = useForm<T>({
mode: "onChange",
defaultValues: defaultValues,
resolver: validationSchema
? createZodResolver(validationSchema)
: undefined,
shouldFocusError: false,
resetOptions: { keepDefaultValues: true },
values,
});
const methods = propMethods ?? internalMethods;
let methods = propMethods;
if (!methods) {
methods = useForm<
T
>({
mode: "onChange",
defaultValues: defaultValues,
resolver: validationSchema
? createZodResolver(validationSchema)
: undefined,
shouldFocusError: false,
resetOptions: { keepDefaultValues: true },
values,
});
}
const { handleSubmit, control, getValues, formState, getFieldState } =
methods;
useEffect(() => {
if (!propMethods) {
onFormInit?.(methods);
onFormInit?.(internalMethods);
}
}, [onFormInit, propMethods, methods]);
}, [onFormInit, propMethods, internalMethods]);
useEffect(() => {
const errorKeys = Object.keys(formState.errors);
if (formId) {
if (
errorKeys.length === 0
) {
if (errorKeys.length === 0) {
dotPaths(getValues()).forEach((key) => {
removeError(key);
});
@@ -132,19 +125,27 @@ export function DynamicForm<T extends FieldValues>({
addError(formId, "");
}
}
}, [formState.errors]);
}, [formState.errors, addError, formId, getValues, removeError]);
const isDisabled = (
disabledBy?: DisabledBy<T>[],
disabled?: boolean,
): boolean => {
if (disabled) return true;
if (!disabledBy) return false;
if (disabled) {
return true;
}
if (!disabledBy) {
return false;
}
return disabledBy.some((field) => {
const value = getValues(field.fieldName);
if (value === "always") return true;
if (typeof value === "boolean") return field.invert ? value : !value;
if (value === "always") {
return true;
}
if (typeof value === "boolean") {
return field.invert ? value : !value;
}
if (typeof value === "number") {
return field.invert
? field.selector !== value
@@ -181,14 +182,16 @@ export function DynamicForm<T extends FieldValues>({
fieldName={field.name}
description={field.description}
valid={!error}
validationText={error
? String(
t([`formValidation.${error.type}`, error.message], {
returnObjects: false,
...error.params,
}),
)
: ""}
validationText={
error
? String(
t([`formValidation.${error.type}`, error.message], {
returnObjects: false,
...error.params,
}),
)
: ""
}
>
<DynamicFormField
field={field}
@@ -203,11 +206,7 @@ export function DynamicForm<T extends FieldValues>({
</div>
))}
{hasSubmitButton && (
<Button
type="submit"
variant="outline"
disabled={!formState.isValid}
>
<Button type="submit" variant="outline" disabled={!formState.isValid}>
{t("button.submit")}
</Button>
)}
@@ -1,7 +1,3 @@
import {
type MultiSelectFieldProps,
MultiSelectInput,
} from "./FormMultiSelect.tsx";
import {
GenericInput,
type InputFieldProps,
@@ -19,6 +15,10 @@ import {
ToggleInput,
} from "@components/Form/FormToggle.tsx";
import type { Control, FieldValues } from "react-hook-form";
import {
type MultiSelectFieldProps,
MultiSelectInput,
} from "./FormMultiSelect.tsx";
export type FieldProps<T> =
| InputFieldProps<T>
+11 -6
View File
@@ -58,7 +58,9 @@ export function GenericInput<T extends FieldValues>({
return;
}
if (field.inputChange) field.inputChange(e);
if (field.inputChange) {
field.inputChange(e);
}
controllerField.onChange(
field.type === "number"
@@ -76,14 +78,17 @@ export function GenericInput<T extends FieldValues>({
<Input
type={field.type}
step={field.properties?.step}
value={field.type === "number"
? String(controllerField.value)
: controllerField.value}
value={
field.type === "number"
? String(controllerField.value)
: controllerField.value
}
id={field.name}
onChange={handleInputChange}
showCopyButton={field.properties?.showCopyButton}
showPasswordToggle={field.properties?.showPasswordToggle ||
field.type === "password"}
showPasswordToggle={
field.properties?.showPasswordToggle || field.type === "password"
}
className={field.properties?.className}
{...restProperties}
disabled={disabled}
@@ -2,11 +2,11 @@ import type {
BaseFormBuilderProps,
GenericFormElementProps,
} from "@components/Form/DynamicForm.tsx";
import type { FLAGS_CONFIG } from "@core/hooks/usePositionFlags.ts";
import { cn } from "@core/utils/cn.ts";
import type { FieldValues } from "react-hook-form";
import { useTranslation } from "react-i18next";
import type { FLAGS_CONFIG } from "@core/hooks/usePositionFlags.ts";
import { MultiSelect, MultiSelectItem } from "../UI/MultiSelect.tsx";
import { cn } from "@core/utils/cn.ts";
export interface MultiSelectFieldProps<T> extends BaseFormBuilderProps<T> {
type: "multiSelect";
@@ -15,9 +15,7 @@ export interface MultiSelectFieldProps<T> extends BaseFormBuilderProps<T> {
isChecked: (name: string) => boolean;
value: string[];
properties: BaseFormBuilderProps<T>["properties"] & {
enumValue:
| { [s: string]: string | number }
| typeof FLAGS_CONFIG;
enumValue: { [s: string]: string | number } | typeof FLAGS_CONFIG;
formatEnumName?: boolean;
};
}
@@ -39,7 +37,7 @@ export function MultiSelectInput<T extends FieldValues>({
([key, configOrValue]) => {
if (isNewConfigStructure) {
const config =
configOrValue as typeof FLAGS_CONFIG[keyof typeof FLAGS_CONFIG];
configOrValue as (typeof FLAGS_CONFIG)[keyof typeof FLAGS_CONFIG];
return {
key,
display: t(config.i18nKey),
@@ -2,11 +2,11 @@ import type {
BaseFormBuilderProps,
GenericFormElementProps,
} from "@components/Form/DynamicForm.tsx";
import type { ButtonVariant } from "../UI/Button.tsx";
import { Generator } from "@components/UI/Generator.tsx";
import { Controller, type FieldValues, useFormContext } from "react-hook-form";
import { usePasswordVisibilityToggle } from "@core/hooks/usePasswordVisibilityToggle.ts";
import { useEffect } from "react";
import { Controller, type FieldValues, useFormContext } from "react-hook-form";
import type { ButtonVariant } from "../UI/Button.tsx";
export interface PasswordGeneratorProps<T> extends BaseFormBuilderProps<T> {
type: "passwordGenerator";
@@ -38,24 +38,22 @@ export function PasswordGenerator<T extends FieldValues>({
useEffect(() => {
trigger(field.name);
}, [field.devicePSKBitCount, field.name, trigger]);
}, [field.name, trigger]);
return (
<Controller
name={field.name}
control={control}
render={(
{
field: { value, onChange, ...rest },
},
) => (
render={({ field: { value, onChange, ...rest } }) => (
<Generator
type={field.hide && !isVisible ? "password" : "text"}
id={field.id}
devicePSKBitCount={field.devicePSKBitCount}
bits={field.bits}
inputChange={(e) => {
if (field.inputChange) field.inputChange(e);
if (field.inputChange) {
field.inputChange(e);
}
onChange(e);
}}
selectChange={field.selectChange ?? (() => {})}
@@ -9,8 +9,8 @@ import {
SelectTrigger,
SelectValue,
} from "@components/UI/Select.tsx";
import { type FieldValues, useController } from "react-hook-form";
import { cn } from "@core/utils/cn.ts";
import { type FieldValues, useController } from "react-hook-form";
export interface SelectFieldProps<T> extends BaseFormBuilderProps<T> {
type: "select";
@@ -73,10 +73,14 @@ export function SelectInput<T extends FieldValues>({
if (field.validate) {
const isValid = await field.validate(selectedKey);
if (!isValid) return;
if (!isValid) {
return;
}
}
if (field.selectChange) field.selectChange(newValue, selectedKey);
if (field.selectChange) {
field.selectChange(newValue, selectedKey);
}
onChange(Number.parseInt(newValue));
};
@@ -3,8 +3,8 @@ import type {
GenericFormElementProps,
} from "@components/Form/DynamicForm.tsx";
import { Switch } from "@components/UI/Switch.tsx";
import { Controller, type FieldValues } from "react-hook-form";
import { cn } from "@core/utils/cn.ts";
import { Controller, type FieldValues } from "react-hook-form";
export interface ToggleFieldProps<T> extends BaseFormBuilderProps<T> {
type: "toggle";
@@ -22,9 +22,7 @@ export function ToggleInput<T extends FieldValues>({
<Controller
name={field.name}
control={control}
render={(
{ field: { value, onChange, ...rest } },
) => (
render={({ field: { value, onChange, ...rest } }) => (
<Switch
checked={value}
onCheckedChange={(v) => {
@@ -1,4 +1,3 @@
import type { ZodType } from "zod/v4";
import type {
FieldError,
FieldValues,
@@ -6,6 +5,7 @@ import type {
ResolverOptions,
ResolverResult,
} from "react-hook-form";
import type { ZodType } from "zod/v4";
export function createZodResolver<T extends FieldValues>(
schema: ZodType<T, unknown>,
@@ -32,18 +32,18 @@ export function createZodResolver<T extends FieldValues>(
const { path, code, message, ...params } = issue;
const key = path.join(".");
const suffix = "format" in params
? params.format
: "origin" in params
? params.origin
: "expected" in params
? params.expected
: "";
const suffix =
"format" in params
? params.format
: "origin" in params
? params.origin
: "expected" in params
? params.expected
: "";
const newCode = code.replace(
/_([a-z])/g,
(_, char) => char.toUpperCase(),
) + (suffix ? `.${suffix}` : "");
const newCode =
code.replace(/_([a-z])/g, (_, char) => char.toUpperCase()) +
(suffix ? `.${suffix}` : "");
const fieldError: FieldError & { params?: Record<string, unknown> } = {
type: newCode,
@@ -11,6 +11,5 @@ export const KeyBackupReminder = () => {
onAccept: () => setDialogOpen("pkiBackup", true),
enabled: true,
});
// deno-lint-ignore jsx-no-useless-fragment
return <></>;
return null;
};
@@ -1,7 +1,10 @@
import { Check, Languages } from "lucide-react";
import { useTranslation } from "react-i18next";
import { LangCode, supportedLanguages } from "@app/i18n-config.ts";
import { type LangCode, supportedLanguages } from "@app/i18n-config.ts";
import useLang from "@core/hooks/useLang.ts";
import { cn } from "@core/utils/cn.ts";
import { Check, Languages } from "lucide-react";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "./UI/Button.tsx";
import {
DropdownMenu,
DropdownMenuContent,
@@ -9,8 +12,6 @@ import {
DropdownMenuTrigger,
} from "./UI/DropdownMenu.tsx";
import { Subtle } from "./UI/Typography/Subtle.tsx";
import { cn } from "@core/utils/cn.ts";
import { Button } from "./UI/Button.tsx";
interface LanguageSwitcherProps {
disableHover?: boolean;
@@ -22,9 +23,12 @@ export default function LanguageSwitcher({
const { i18n } = useTranslation("ui");
const { set: setLanguage, currentLanguage } = useLang();
const handleLanguageChange = async (languageCode: LangCode) => {
await setLanguage(languageCode, true);
};
const handleLanguageChange = useCallback(
async (languageCode: LangCode) => {
await setLanguage(languageCode, true);
},
[setLanguage],
);
return (
<DropdownMenu>
+8 -8
View File
@@ -1,25 +1,27 @@
import { useTheme } from "@core/hooks/useTheme.ts";
import { useEffect, useRef } from "react";
import MapGl, {
AttributionControl,
type MapRef,
NavigationControl,
ScaleControl,
} from "react-map-gl/maplibre";
import { useTheme } from "@core/hooks/useTheme.ts";
import { useEffect, useRef } from "react";
interface MapProps {
children?: React.ReactNode;
onLoad?: (map: MapRef) => void;
}
export const Map = ({ children, onLoad }: MapProps) => {
export const BaseMap = ({ children, onLoad }: MapProps) => {
const { theme } = useTheme();
const darkMode = theme === "dark";
const mapRef = useRef<MapRef | null>(null);
useEffect(() => {
const map = mapRef.current;
if (map && onLoad) onLoad(map);
if (map && onLoad) {
onLoad(map);
}
}, [onLoad]);
return (
@@ -45,14 +47,12 @@ export const Map = ({ children, onLoad }: MapProps) => {
}}
/>
{/* { Disabled for now until we can use i18n for the geolocate control} */}
{
/* <GeolocateControl
{/* <GeolocateControl
position="top-right"
i18nIsDynamicList
positionOptions={{ enableHighAccuracy: true }}
trackUserLocation
/> */
}
/> */}
<NavigationControl position="top-right" showCompass={false} />
<ScaleControl />
{children}
@@ -1,5 +1,6 @@
import { makeChannelSchema } from "@app/validation/channel.ts";
import { create } from "@bufbuild/protobuf";
import { PkiRegenerateDialog } from "@components/Dialog/PkiRegenerateDialog.tsx";
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import { useToast } from "@core/hooks/useToast.ts";
import { useDevice } from "@core/stores/deviceStore.ts";
@@ -8,8 +9,7 @@ import { fromByteArray, toByteArray } from "base64-js";
import cryptoRandomString from "crypto-random-string";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { PkiRegenerateDialog } from "@components/Dialog/PkiRegenerateDialog.tsx";
import { infer as zodInfer } from "zod/v4";
import type { infer as zodInfer } from "zod/v4";
export interface SettingsPanelProps {
channel: Protobuf.Channel.Channel;
@@ -20,9 +20,8 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
const { t } = useTranslation(["channels", "ui", "dialog"]);
const { toast } = useToast();
const [preSharedDialogOpen, setPreSharedDialogOpen] = useState<boolean>(
false,
);
const [preSharedDialogOpen, setPreSharedDialogOpen] =
useState<boolean>(false);
const [pass, setPass] = useState<string>(
fromByteArray(channel?.settings?.psk ?? new Uint8Array(0)),
);
@@ -30,12 +29,9 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
channel?.settings?.psk.length ?? 16,
);
const ChannelValidationSchema = useMemo(
() => {
return makeChannelSchema(byteCount);
},
[byteCount],
);
const ChannelValidationSchema = useMemo(() => {
return makeChannelSchema(byteCount);
}, [byteCount]);
type ChannelValidation = zodInfer<typeof ChannelValidationSchema>;
@@ -52,10 +48,12 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
},
});
connection?.setChannel(channel).then(() => {
console.debug(t("toast.savedChannel.title", {
ns: "ui",
channelName: channel.settings?.name,
}));
console.debug(
t("toast.savedChannel.title", {
ns: "ui",
channelName: channel.settings?.name,
}),
);
toast({
title: t("toast.savedChannel.title", {
ns: "ui",
@@ -108,7 +106,7 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
...channel?.settings?.moduleSettings,
positionPrecision:
channel?.settings?.moduleSettings?.positionPrecision ===
undefined
undefined
? 10
: channel?.settings?.moduleSettings?.positionPrecision,
},
@@ -127,12 +125,13 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
disabled: channel.index === 0,
description: t("role.description"),
properties: {
enumValue: channel.index === 0
? { [t("role.options.primary")]: 1 }
: {
[t("role.options.disabled")]: 0,
[t("role.options.secondary")]: 2,
},
enumValue:
channel.index === 0
? { [t("role.options.primary")]: 1 }
: {
[t("role.options.disabled")]: 0,
[t("role.options.secondary")]: 2,
},
},
},
{
@@ -182,79 +181,36 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
label: t("positionPrecision.label"),
description: t("positionPrecision.description"),
properties: {
enumValue: config.display?.units === 0
? {
[t("positionPrecision.options.none")]: 0,
[
t("positionPrecision.options.metric_km23")
]: 10,
[
t("positionPrecision.options.metric_km12")
]: 11,
[
t("positionPrecision.options.metric_km5_8")
]: 12,
[
t("positionPrecision.options.metric_km2_9")
]: 13,
[
t("positionPrecision.options.metric_km1_5")
]: 14,
[
t("positionPrecision.options.metric_m700")
]: 15,
[
t("positionPrecision.options.metric_m350")
]: 16,
[
t("positionPrecision.options.metric_m200")
]: 17,
[
t("positionPrecision.options.metric_m90")
]: 18,
[
t("positionPrecision.options.metric_m50")
]: 19,
[
t("positionPrecision.options.precise")
]: 32,
}
: {
[t("positionPrecision.options.none")]: 0,
[
t("positionPrecision.options.imperial_mi15")
]: 10,
[
t("positionPrecision.options.imperial_mi7_3")
]: 11,
[
t("positionPrecision.options.imperial_mi3_6")
]: 12,
[
t("positionPrecision.options.imperial_mi1_8")
]: 13,
[
t("positionPrecision.options.imperial_mi0_9")
]: 14,
[
t("positionPrecision.options.imperial_mi0_5")
]: 15,
[
t("positionPrecision.options.imperial_mi0_2")
]: 16,
[
t("positionPrecision.options.imperial_ft600")
]: 17,
[
t("positionPrecision.options.imperial_ft300")
]: 18,
[
t("positionPrecision.options.imperial_ft150")
]: 19,
[
t("positionPrecision.options.precise")
]: 32,
},
enumValue:
config.display?.units === 0
? {
[t("positionPrecision.options.none")]: 0,
[t("positionPrecision.options.metric_km23")]: 10,
[t("positionPrecision.options.metric_km12")]: 11,
[t("positionPrecision.options.metric_km5_8")]: 12,
[t("positionPrecision.options.metric_km2_9")]: 13,
[t("positionPrecision.options.metric_km1_5")]: 14,
[t("positionPrecision.options.metric_m700")]: 15,
[t("positionPrecision.options.metric_m350")]: 16,
[t("positionPrecision.options.metric_m200")]: 17,
[t("positionPrecision.options.metric_m90")]: 18,
[t("positionPrecision.options.metric_m50")]: 19,
[t("positionPrecision.options.precise")]: 32,
}
: {
[t("positionPrecision.options.none")]: 0,
[t("positionPrecision.options.imperial_mi15")]: 10,
[t("positionPrecision.options.imperial_mi7_3")]: 11,
[t("positionPrecision.options.imperial_mi3_6")]: 12,
[t("positionPrecision.options.imperial_mi1_8")]: 13,
[t("positionPrecision.options.imperial_mi0_9")]: 14,
[t("positionPrecision.options.imperial_mi0_5")]: 15,
[t("positionPrecision.options.imperial_mi0_2")]: 16,
[t("positionPrecision.options.imperial_ft600")]: 17,
[t("positionPrecision.options.imperial_ft300")]: 18,
[t("positionPrecision.options.imperial_ft150")]: 19,
[t("positionPrecision.options.precise")]: 32,
},
},
},
],
@@ -1,3 +1,4 @@
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import {
type BluetoothValidation,
BluetoothValidationSchema,
@@ -8,14 +9,16 @@ import {
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface BluetoothConfigProps {
onFormInit: DynamicFormFormInit<BluetoothValidation>;
}
export const Bluetooth = ({ onFormInit }: BluetoothConfigProps) => {
useWaitForConfig({ configCase: "bluetooth" });
const { config, setWorkingConfig, getEffectiveConfig, removeWorkingConfig } =
useDevice();
const { t } = useTranslation("deviceConfig");
@@ -1,37 +0,0 @@
import {
useDevice,
ValidConfigType,
ValidModuleConfigType,
} from "@core/stores/deviceStore.ts";
import { useEffect, useState } from "react";
export function ConfigSuspender({
configCase,
moduleConfigCase,
children,
}: {
configCase?: ValidConfigType;
moduleConfigCase?: ValidModuleConfigType;
children: React.ReactNode;
}) {
const { config, moduleConfig } = useDevice();
let cfg = undefined;
if (configCase) {
cfg = config[configCase];
} else if (moduleConfigCase) {
cfg = moduleConfig[moduleConfigCase];
} else {
return children;
}
const [ready, setReady] = useState(() => cfg !== undefined);
useEffect(() => {
if (cfg !== undefined) setReady(true);
}, [cfg]);
if (!ready) throw new Promise(() => {}); // triggers suspense fallback
return children;
}
@@ -1,22 +1,25 @@
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import {
type DeviceValidation,
DeviceValidationSchema,
} from "@app/validation/config/device.ts";
import { create } from "@bufbuild/protobuf";
import { useUnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core";
import { useUnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
interface DeviceConfigProps {
onFormInit: DynamicFormFormInit<DeviceValidation>;
}
export const Device = ({ onFormInit }: DeviceConfigProps) => {
useWaitForConfig({ configCase: "device" });
const { config, setWorkingConfig, getEffectiveConfig, removeWorkingConfig } =
useDevice();
const { t } = useTranslation("deviceConfig");
@@ -113,8 +116,8 @@ export const Device = ({ onFormInit }: DeviceConfigProps) => {
properties: {
fieldLength: {
max: 64,
currentValueLength: getEffectiveConfig("device")?.tzdef
?.length,
currentValueLength:
getEffectiveConfig("device")?.tzdef?.length,
showCharacterCount: true,
},
},
@@ -1,3 +1,4 @@
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import {
type DisplayValidation,
DisplayValidationSchema,
@@ -8,14 +9,15 @@ import {
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface DisplayConfigProps {
onFormInit: DynamicFormFormInit<DisplayValidation>;
}
export const Display = ({ onFormInit }: DisplayConfigProps) => {
useWaitForConfig({ configCase: "display" });
const { config, setWorkingConfig, getEffectiveConfig, removeWorkingConfig } =
useDevice();
const { t } = useTranslation("deviceConfig");
@@ -1,3 +1,4 @@
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import {
type LoRaValidation,
LoRaValidationSchema,
@@ -8,14 +9,16 @@ import {
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface LoRaConfigProps {
onFormInit: DynamicFormFormInit<LoRaValidation>;
}
export const LoRa = ({ onFormInit }: LoRaConfigProps) => {
useWaitForConfig({ configCase: "lora" });
const { config, setWorkingConfig, getEffectiveConfig, removeWorkingConfig } =
useDevice();
const { t } = useTranslation("deviceConfig");
@@ -1,3 +1,4 @@
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import {
type NetworkValidation,
NetworkValidationSchema,
@@ -8,18 +9,20 @@ import {
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import {
convertIntToIpAddress,
convertIpAddressToInt,
} from "@core/utils/ip.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface NetworkConfigProps {
onFormInit: DynamicFormFormInit<NetworkValidation>;
}
export const Network = ({ onFormInit }: NetworkConfigProps) => {
useWaitForConfig({ configCase: "network" });
const { config, setWorkingConfig, getEffectiveConfig, removeWorkingConfig } =
useDevice();
const { t } = useTranslation("deviceConfig");
@@ -72,24 +75,28 @@ export const Network = ({ onFormInit }: NetworkConfigProps) => {
),
dns: convertIntToIpAddress(config.network?.ipv4Config?.dns ?? 0),
},
enabledProtocols: config.network?.enabledProtocols ??
enabledProtocols:
config.network?.enabledProtocols ??
Protobuf.Config.Config_NetworkConfig_ProtocolFlags.NO_BROADCAST,
}}
values={{
...networkConfig,
ipv4Config: {
ip: convertIntToIpAddress(networkConfig?.ipv4Config?.ip ?? 0),
gateway: convertIntToIpAddress(
networkConfig?.ipv4Config?.gateway ?? 0,
),
subnet: convertIntToIpAddress(
networkConfig?.ipv4Config?.subnet ?? 0,
),
dns: convertIntToIpAddress(networkConfig?.ipv4Config?.dns ?? 0),
},
enabledProtocols: networkConfig?.enabledProtocols ??
Protobuf.Config.Config_NetworkConfig_ProtocolFlags.NO_BROADCAST,
} as NetworkValidation}
values={
{
...networkConfig,
ipv4Config: {
ip: convertIntToIpAddress(networkConfig?.ipv4Config?.ip ?? 0),
gateway: convertIntToIpAddress(
networkConfig?.ipv4Config?.gateway ?? 0,
),
subnet: convertIntToIpAddress(
networkConfig?.ipv4Config?.subnet ?? 0,
),
dns: convertIntToIpAddress(networkConfig?.ipv4Config?.dns ?? 0),
},
enabledProtocols:
networkConfig?.enabledProtocols ??
Protobuf.Config.Config_NetworkConfig_ProtocolFlags.NO_BROADCAST,
} as NetworkValidation
}
fieldGroups={[
{
label: t("network.title"),
@@ -1,7 +1,4 @@
import {
type FlagName,
usePositionFlags,
} from "@core/hooks/usePositionFlags.ts";
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import {
type PositionValidation,
PositionValidationSchema,
@@ -11,16 +8,22 @@ import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import {
type FlagName,
usePositionFlags,
} from "@core/hooks/usePositionFlags.ts";
import { useDevice } from "@core/stores/deviceStore.ts";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface PositionConfigProps {
onFormInit: DynamicFormFormInit<PositionValidation>;
}
export const Position = ({ onFormInit }: PositionConfigProps) => {
useWaitForConfig({ configCase: "position" });
const { setWorkingConfig, config, getEffectiveConfig, removeWorkingConfig } =
useDevice();
const { flagsValue, activeFlags, toggleFlag, getAllFlags } = usePositionFlags(
@@ -1,3 +1,4 @@
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import {
type PowerValidation,
PowerValidationSchema,
@@ -8,14 +9,16 @@ import {
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface PowerConfigProps {
onFormInit: DynamicFormFormInit<PowerValidation>;
}
export const Power = ({ onFormInit }: PowerConfigProps) => {
useWaitForConfig({ configCase: "power" });
const { setWorkingConfig, config, getEffectiveConfig, removeWorkingConfig } =
useDevice();
const { t } = useTranslation("deviceConfig");
@@ -1,30 +1,33 @@
import { PkiRegenerateDialog } from "@components/Dialog/PkiRegenerateDialog.tsx";
import { ManagedModeDialog } from "@components/Dialog/ManagedModeDialog.tsx";
import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useAppStore } from "@core/stores/appStore.ts";
import { getX25519PrivateKey, getX25519PublicKey } from "@core/utils/x25519.ts";
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import {
type ParsedSecurity,
type RawSecurity,
RawSecuritySchema,
} from "@app/validation/config/security.ts";
import { useEffect, useState } from "react";
import { create } from "@bufbuild/protobuf";
import { ManagedModeDialog } from "@components/Dialog/ManagedModeDialog.tsx";
import { PkiRegenerateDialog } from "@components/Dialog/PkiRegenerateDialog.tsx";
import { createZodResolver } from "@components/Form/createZodResolver.ts";
import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDevice } from "@core/stores/deviceStore.ts";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { getX25519PrivateKey, getX25519PublicKey } from "@core/utils/x25519.ts";
import { Protobuf } from "@meshtastic/core";
import { fromByteArray, toByteArray } from "base64-js";
import { useTranslation } from "react-i18next";
import { useEffect, useState } from "react";
import { type DefaultValues, useForm } from "react-hook-form";
import { createZodResolver } from "@components/Form/createZodResolver.ts";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { useTranslation } from "react-i18next";
interface SecurityConfigProps {
onFormInit: DynamicFormFormInit<RawSecurity>;
}
export const Security = ({ onFormInit }: SecurityConfigProps) => {
useWaitForConfig({ configCase: "security" });
const {
config,
setWorkingConfig,
@@ -43,19 +46,11 @@ export const Security = ({ onFormInit }: SecurityConfigProps) => {
privateKey: fromByteArray(
securityConfig?.privateKey ?? new Uint8Array(0),
),
publicKey: fromByteArray(
securityConfig?.publicKey ?? new Uint8Array(0),
),
publicKey: fromByteArray(securityConfig?.publicKey ?? new Uint8Array(0)),
adminKey: [
fromByteArray(
securityConfig?.adminKey?.at(0) ?? new Uint8Array(0),
),
fromByteArray(
securityConfig?.adminKey?.at(1) ?? new Uint8Array(0),
),
fromByteArray(
securityConfig?.adminKey?.at(2) ?? new Uint8Array(0),
),
fromByteArray(securityConfig?.adminKey?.at(0) ?? new Uint8Array(0)),
fromByteArray(securityConfig?.adminKey?.at(1) ?? new Uint8Array(0)),
fromByteArray(securityConfig?.adminKey?.at(2) ?? new Uint8Array(0)),
],
},
};
@@ -73,15 +68,15 @@ export const Security = ({ onFormInit }: SecurityConfigProps) => {
onFormInit?.(formMethods);
}, [onFormInit, formMethods]);
const [privateKeyDialogOpen, setPrivateKeyDialogOpen] = useState<boolean>(
false,
);
const [managedModeDialogOpen, setManagedModeDialogOpen] = useState<boolean>(
false,
);
const [privateKeyDialogOpen, setPrivateKeyDialogOpen] =
useState<boolean>(false);
const [managedModeDialogOpen, setManagedModeDialogOpen] =
useState<boolean>(false);
const onSubmit = (data: RawSecurity) => {
if (!formState.isReady) return;
if (!formState.isReady) {
return;
}
const payload: ParsedSecurity = {
...data,
@@ -1,19 +1,17 @@
import type { TabElementProps } from "../../Dialog/NewDeviceDialog.tsx";
import { Button } from "@components/UI/Button.tsx";
import { Mono } from "@components/generic/Mono.tsx";
import { Button } from "@components/UI/Button.tsx";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDeviceStore } from "@core/stores/deviceStore.ts";
import { useMessageStore } from "@core/stores/messageStore/index.ts";
import { subscribeAll } from "@core/subscriptions.ts";
import { randId } from "@core/utils/randId.ts";
import { TransportWebBluetooth } from "@meshtastic/transport-web-bluetooth";
import { MeshDevice } from "@meshtastic/core";
import { TransportWebBluetooth } from "@meshtastic/transport-web-bluetooth";
import { useCallback, useEffect, useState } from "react";
import { useMessageStore } from "@core/stores/messageStore/index.ts";
import { useTranslation } from "react-i18next";
import type { TabElementProps } from "../../Dialog/NewDeviceDialog.tsx";
export const BLE = (
{ closeDialog }: TabElementProps,
) => {
export const BLE = ({ closeDialog }: TabElementProps) => {
const [connectionInProgress, setConnectionInProgress] = useState(false);
const [bleDevices, setBleDevices] = useState<BluetoothDevice[]>([]);
const { addDevice } = useDeviceStore();
@@ -77,10 +75,12 @@ export const BLE = (
if (exists === -1) {
setBleDevices(bleDevices.concat(device));
}
}).catch((error: Error) => {
})
.catch((error: Error) => {
console.error("Error requesting device:", error);
setConnectionInProgress(false);
}).finally(() => {
})
.finally(() => {
setConnectionInProgress(false);
});
}}
@@ -1,7 +1,7 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { HTTP } from "@components/PageComponents/Connect/HTTP.tsx";
import { MeshDevice } from "@meshtastic/core";
import { TransportHTTP } from "@meshtastic/transport-http";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
vi.mock("@core/stores/appStore.ts", () => ({
@@ -35,8 +35,9 @@ describe("HTTP Component", () => {
render(<HTTP closeDialog={vi.fn()} />);
expect(screen.getByText("IP Address/Hostname")).toBeInTheDocument();
expect(screen.getByRole("textbox")).toBeInTheDocument();
expect(screen.getByPlaceholderText("000.000.000.000 / meshtastic.local"))
.toBeInTheDocument();
expect(
screen.getByPlaceholderText("000.000.000.000 / meshtastic.local"),
).toBeInTheDocument();
expect(screen.getByText("Use HTTPS")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Connect" })).toBeInTheDocument();
});
@@ -45,8 +46,9 @@ describe("HTTP Component", () => {
render(<HTTP closeDialog={vi.fn()} />);
const inputField = screen.getByRole("textbox");
fireEvent.change(inputField, { target: { value: "meshtastic.local" } });
expect(screen.getByPlaceholderText("000.000.000.000 / meshtastic.local"))
.toBeInTheDocument();
expect(
screen.getByPlaceholderText("000.000.000.000 / meshtastic.local"),
).toBeInTheDocument();
});
it("toggles HTTPS switch and updates prefix", () => {
@@ -1,19 +1,19 @@
import type { TabElementProps } from "@components/Dialog/NewDeviceDialog.tsx";
import { Button } from "@components/UI/Button.tsx";
import { Input } from "@components/UI/Input.tsx";
import { Link } from "@components/UI/Typography/Link.tsx";
import { Label } from "@components/UI/Label.tsx";
import { Switch } from "@components/UI/Switch.tsx";
import { Link } from "@components/UI/Typography/Link.tsx";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDeviceStore } from "@core/stores/deviceStore.ts";
import { useMessageStore } from "@core/stores/messageStore/index.ts";
import { subscribeAll } from "@core/subscriptions.ts";
import { randId } from "@core/utils/randId.ts";
import { MeshDevice } from "@meshtastic/core";
import { TransportHTTP } from "@meshtastic/transport-http";
import { AlertTriangle } from "lucide-react";
import { useState } from "react";
import { useController, useForm } from "react-hook-form";
import { AlertTriangle } from "lucide-react";
import { useMessageStore } from "@core/stores/messageStore/index.ts";
import { useTranslation } from "react-i18next";
interface FormData {
@@ -21,9 +21,7 @@ interface FormData {
tls: boolean;
}
export const HTTP = (
{ closeDialog }: TabElementProps,
) => {
export const HTTP = ({ closeDialog }: TabElementProps) => {
const { t } = useTranslation("dialog");
const [connectionInProgress, setConnectionInProgress] = useState(false);
const isURLHTTPS = location.protocol === "https:";
@@ -35,11 +33,11 @@ export const HTTP = (
const { control, handleSubmit, register } = useForm<FormData>({
defaultValues: {
ip: ["client.meshtastic.org", "localhost"].includes(
globalThis.location.hostname,
)
globalThis.location.hostname,
)
? "meshtastic.local"
: globalThis.location.host,
tls: isURLHTTPS ? true : false,
tls: !!isURLHTTPS,
},
});
@@ -47,9 +45,10 @@ export const HTTP = (
field: { value: tlsValue, onChange: setTLS },
} = useController({ name: "tls", control });
const [connectionError, setConnectionError] = useState<
{ host: string; secure: boolean } | null
>(null);
const [connectionError, setConnectionError] = useState<{
host: string;
secure: boolean;
} | null>(null);
const onSubmit = handleSubmit(async (data) => {
setConnectionInProgress(true);
@@ -85,9 +84,11 @@ export const HTTP = (
<div>
<Label>{t("newDeviceDialog.httpConnection.label")}</Label>
<Input
prefix={tlsValue
? `${t("newDeviceDialog.https")}://`
: `${t("newDeviceDialog.http")}://`}
prefix={
tlsValue
? `${t("newDeviceDialog.https")}://`
: `${t("newDeviceDialog.http")}://`
}
placeholder={t("newDeviceDialog.httpConnection.placeholder")}
className="text-slate-900 dark:text-slate-100"
{...register("ip")}
@@ -136,9 +137,10 @@ export const HTTP = (
{t("newDeviceDialog.connectionFailedAlert.openLinkSuffix")}
{connectionError.secure
? t(
"newDeviceDialog.connectionFailedAlert.acceptTlsWarningSuffix",
)
: ""}.{" "}
"newDeviceDialog.connectionFailedAlert.acceptTlsWarningSuffix",
)
: ""}
.{" "}
<Link
href="https://meshtastic.org/docs/software/web-client/#http"
className="underline font-medium text-amber-800 dark:text-amber-800"
@@ -151,10 +153,7 @@ export const HTTP = (
</div>
)}
</fieldset>
<Button
type="submit"
variant="default"
>
<Button type="submit" variant="default">
<span>
{connectionInProgress
? t("newDeviceDialog.connecting")
@@ -1,19 +1,17 @@
import type { TabElementProps } from "../../Dialog/NewDeviceDialog.tsx";
import { Button } from "@components/UI/Button.tsx";
import { Mono } from "@components/generic/Mono.tsx";
import { Button } from "@components/UI/Button.tsx";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDeviceStore } from "@core/stores/deviceStore.ts";
import { useMessageStore } from "@core/stores/messageStore/index.ts";
import { subscribeAll } from "@core/subscriptions.ts";
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 { useTranslation } from "react-i18next";
import { useMessageStore } from "@core/stores/messageStore/index.ts";
import type { TabElementProps } from "../../Dialog/NewDeviceDialog.tsx";
export const Serial = (
{ closeDialog }: TabElementProps,
) => {
export const Serial = ({ closeDialog }: TabElementProps) => {
const [connectionInProgress, setConnectionInProgress] = useState(false);
const [serialPorts, setSerialPorts] = useState<SerialPort[]>([]);
const { addDevice } = useDeviceStore();
@@ -54,13 +52,13 @@ export const Serial = (
disabled={connectionInProgress}
>
<div className="flex h-48 flex-col gap-2 overflow-y-auto">
{serialPorts.map((port, index) => {
{serialPorts.map((port) => {
const { usbProductId, usbVendorId } = port.getInfo();
const vendor = usbVendorId ?? t("unknown.shortName");
const product = usbProductId ?? t("unknown.shortName");
return (
<Button
key={`${vendor}-${product}-${index}`}
key={`${vendor}-${product}`}
disabled={port.readable !== null}
variant="default"
onClick={async () => {
@@ -70,7 +68,6 @@ export const Serial = (
}}
>
{t("newDeviceDialog.serialConnection.deviceIdentifier", {
index: index,
vendorId: vendor,
productId: product,
})}
@@ -86,14 +83,18 @@ export const Serial = (
<Button
variant="default"
onClick={async () => {
await navigator.serial.requestPort().then((port) => {
setSerialPorts(serialPorts.concat(port));
// No need to setConnectionInProgress(false) here if requestPort is quick
}).catch((error: Error) => {
console.error("Error requesting port:", error);
}).finally(() => {
setConnectionInProgress(false);
});
await navigator.serial
.requestPort()
.then((port) => {
setSerialPorts(serialPorts.concat(port));
// No need to setConnectionInProgress(false) here if requestPort is quick
})
.catch((error: Error) => {
console.error("Error requesting port:", error);
})
.finally(() => {
setConnectionInProgress(false);
});
}}
>
<span>{t("newDeviceDialog.serialConnection.newDeviceButton")}</span>
@@ -1,12 +1,21 @@
import BatteryStatus from "@components/BatteryStatus.tsx";
import { Mono } from "@components/generic/Mono.tsx";
import { TimeAgo } from "@components/generic/TimeAgo.tsx";
import { Avatar } from "@components/UI/Avatar.tsx";
import { Separator } from "@components/UI/Seperator.tsx";
import { Heading } from "@components/UI/Typography/Heading.tsx";
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
import { formatQuantity } from "@core/utils/string.ts";
import { Avatar } from "@components/UI/Avatar.tsx";
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 { Protobuf } from "@meshtastic/core";
import {
Tooltip,
TooltipContent,
TooltipPortal,
TooltipProvider,
TooltipTrigger,
} from "@radix-ui/react-tooltip";
import { useNavigate } from "@tanstack/react-router";
import {
Dot,
LockIcon,
@@ -15,16 +24,7 @@ import {
MountainSnow,
Star,
} from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipPortal,
TooltipProvider,
TooltipTrigger,
} from "@radix-ui/react-tooltip";
import BatteryStatus from "@components/BatteryStatus.tsx";
import { useTranslation } from "react-i18next";
import { useNavigate } from "@tanstack/react-router";
export interface NodeDetailProps {
node: ProtobufType.Mesh.NodeInfo;
@@ -60,23 +60,21 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
e.stopPropagation();
}}
>
{node.user?.publicKey && node.user?.publicKey.length > 0
? (
<LockIcon
className="text-green-600 mb-1.5"
size={12}
strokeWidth={3}
aria-label={t("node_detail_public_key_enabled_aria_label")}
/>
)
: (
<LockOpenIcon
className="text-yellow-500 mb-1.5"
size={12}
strokeWidth={3}
aria-label={t("node_detail_no_public_key_aria_label")}
/>
)}
{node.user?.publicKey && node.user?.publicKey.length > 0 ? (
<LockIcon
className="text-green-600 mb-1.5"
size={12}
strokeWidth={3}
aria-label={t("node_detail_public_key_enabled_aria_label")}
/>
) : (
<LockOpenIcon
className="text-yellow-500 mb-1.5"
size={12}
strokeWidth={3}
aria-label={t("node_detail_no_public_key_aria_label")}
/>
)}
<TooltipProvider>
<Tooltip>
@@ -105,9 +103,11 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
<Star
fill={node.isFavorite ? "black" : "none"}
size={15}
aria-label={node.isFavorite
? t("nodeDetail.favorite.label")
: t("nodeDetail.notFavorite.label")}
aria-label={
node.isFavorite
? t("nodeDetail.favorite.label")
: t("nodeDetail.notFavorite.label")
}
/>
</div>
</div>
@@ -1,6 +1,6 @@
import { MessageItem } from "@components/PageComponents/Messages/MessageItem.tsx";
import type { Message } from "@core/stores/messageStore/types.ts";
import { InboxIcon } from "lucide-react";
import { Message } from "@core/stores/messageStore/types.ts";
import { useTranslation } from "react-i18next";
export interface ChannelChatProps {
@@ -41,10 +41,7 @@ export const MessageActionsMenu = ({
const iconSizeClass = "size-4";
return (
<div
className={cn(hoverIconBarClass)}
onClick={(e) => e.stopPropagation()}
>
<div className={cn(hoverIconBarClass)}>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
@@ -1,3 +1,4 @@
import type { Types } from "@meshtastic/core";
import {
act,
fireEvent,
@@ -6,13 +7,10 @@ import {
waitFor,
} from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { MessageInput, MessageInputProps } from "./MessageInput.tsx";
import { Types } from "@meshtastic/core";
import { MessageInput, type MessageInputProps } from "./MessageInput.tsx";
vi.mock("@components/UI/Button.tsx", () => ({
Button: vi.fn((
{ type, className, children, onClick, onSubmit, ...rest },
) => (
Button: vi.fn(({ type, className, children, onClick, onSubmit, ...rest }) => (
<button
type={type}
className={className}
@@ -26,11 +24,8 @@ vi.mock("@components/UI/Button.tsx", () => ({
}));
vi.mock("@components/UI/Input.tsx", () => ({
Input: vi.fn((
{ autoFocus, minLength, name, placeholder, value, onChange },
) => (
Input: vi.fn(({ minLength, name, placeholder, value, onChange }) => (
<input
autoFocus={autoFocus}
minLength={minLength}
name={name}
placeholder={placeholder}
@@ -225,16 +220,14 @@ describe("MessageInput", () => {
it("should work with broadcast destination for drafts", () => {
const broadcastDest: Types.Destination = "broadcast";
mockGetDraft.mockImplementation((key) =>
key === broadcastDest ? "Broadcast draft" : ""
key === broadcastDest ? "Broadcast draft" : "",
);
renderComponent({ to: broadcastDest });
expect(mockGetDraft).toHaveBeenCalledWith(broadcastDest);
expect(
(screen.getByTestId(
"message-input-field",
) as HTMLInputElement).value,
(screen.getByTestId("message-input-field") as HTMLInputElement).value,
).toBe("Broadcast draft");
const inputElement = screen.getByTestId(
@@ -1,9 +1,9 @@
import { Button } from "@components/UI/Button.tsx";
import { Input } from "@components/UI/Input.tsx";
import { useMessageStore } from "@core/stores/messageStore/index.ts";
import type { Types } from "@meshtastic/core";
import { SendIcon } from "lucide-react";
import { startTransition, useState } from "react";
import { useMessageStore } from "@core/stores/messageStore/index.ts";
import { useTranslation } from "react-i18next";
export interface MessageInputProps {
@@ -12,11 +12,7 @@ export interface MessageInputProps {
maxBytes: number;
}
export const MessageInput = ({
onSend,
to,
maxBytes,
}: MessageInputProps) => {
export const MessageInput = ({ onSend, to, maxBytes }: MessageInputProps) => {
const { setDraft, getDraft, clearDraft } = useMessageStore();
const { t } = useTranslation("messages");
@@ -25,7 +21,7 @@ export const MessageInput = ({
const initialDraft = getDraft(to);
const [localDraft, setLocalDraft] = useState(initialDraft);
const [messageBytes, setMessageBytes] = useState(() =>
calculateBytes(initialDraft)
calculateBytes(initialDraft),
);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -41,7 +37,9 @@ export const MessageInput = ({
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!localDraft.trim()) return;
if (!localDraft.trim()) {
return;
}
// Reset bytes *before* sending (consider if onSend failure needs different handling)
setMessageBytes(0);
@@ -56,9 +54,8 @@ export const MessageInput = ({
<div className="flex gap-2">
<form className="w-full" name="messageInput" onSubmit={handleSubmit}>
<div className="flex grow gap-1">
<label className="w-full">
<label className="w-full" htmlFor="messageInput">
<Input
autoFocus
minLength={1}
name="messageInput"
placeholder={t("sendMessage.placeholder")}
@@ -70,15 +67,13 @@ export const MessageInput = ({
<label
data-testid="byte-counter"
htmlFor="messageInput"
className="flex items-center w-20 p-1 text-sm place-content-end"
>
{messageBytes}/{maxBytes}
</label>
<Button
type="submit"
variant="default"
>
<Button type="submit" variant="default">
<SendIcon size={16} />
</Button>
</div>
@@ -1,3 +1,4 @@
import { Avatar } from "@components/UI/Avatar.tsx";
import {
Tooltip,
TooltipArrow,
@@ -6,18 +7,18 @@ import {
TooltipTrigger,
} from "@components/UI/Tooltip.tsx";
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 {
MessageState,
useMessageStore,
} from "@core/stores/messageStore/index.ts";
import { Protobuf, Types } from "@meshtastic/core";
import { Message } from "@core/stores/messageStore/types.ts";
import type { Message } from "@core/stores/messageStore/types.ts";
import { cn } from "@core/utils/cn.ts";
import { type Protobuf, Types } from "@meshtastic/core";
import type { LucideIcon } from "lucide-react";
import { AlertCircle, CheckCircle2, CircleEllipsis } from "lucide-react";
import { type ReactNode, useMemo } from "react";
import { useTranslation } from "react-i18next";
// import { MessageActionsMenu } from "@components/PageComponents/Messages/MessageActionsMenu.tsx"; // TODO: Uncomment when actions menu is implemented
interface MessageStatusInfo {
@@ -27,12 +28,13 @@ interface MessageStatusInfo {
iconClassName?: string;
}
const StatusTooltip = (
{ statusInfo, children }: {
statusInfo: MessageStatusInfo;
children: ReactNode;
},
) => (
const StatusTooltip = ({
statusInfo,
children,
}: {
statusInfo: MessageStatusInfo;
children: ReactNode;
}) => (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
@@ -77,16 +79,20 @@ export const MessageItem = ({ message }: MessageItemProps) => {
[t],
);
const UNKNOWN_STATUS = useMemo((): MessageStatusInfo => ({
displayText: t("deliveryStatus.unknown.displayText"),
icon: AlertCircle,
ariaLabel: t("deliveryStatus.unknown.label"),
iconClassName: "text-red-500 dark:text-red-400",
}), [t]);
const UNKNOWN_STATUS = useMemo(
(): MessageStatusInfo => ({
displayText: t("deliveryStatus.unknown.displayText"),
icon: AlertCircle,
ariaLabel: t("deliveryStatus.unknown.label"),
iconClassName: "text-red-500 dark:text-red-400",
}),
[t],
);
const getMessageStatusInfo = useMemo(
() => (state: MessageState): MessageStatusInfo =>
MESSAGE_STATUS_MAP[state] ?? UNKNOWN_STATUS,
() =>
(state: MessageState): MessageStatusInfo =>
MESSAGE_STATUS_MAP[state] ?? UNKNOWN_STATUS,
[MESSAGE_STATUS_MAP, UNKNOWN_STATUS],
);
@@ -103,8 +109,8 @@ export const MessageItem = ({ message }: MessageItemProps) => {
const longName = messageUser?.user?.longName;
const derivedShortName = messageUser?.user?.shortName || fallbackName;
const derivedDisplayName = longName || derivedShortName;
const isFavorite = messageUser?.num !== myNodeNum &&
messageUser?.isFavorite;
const isFavorite =
messageUser?.num !== myNodeNum && messageUser?.isFavorite;
return {
displayName: derivedDisplayName,
shortName: derivedShortName,
@@ -116,7 +122,7 @@ export const MessageItem = ({ message }: MessageItemProps) => {
const StatusIconComponent = messageStatusInfo.icon;
const messageDate = useMemo(
() => message.date ? new Date(message.date) : null,
() => (message.date ? new Date(message.date) : null),
[message.date],
);
const locale = i18n.language;
@@ -199,11 +205,9 @@ export const MessageItem = ({ message }: MessageItemProps) => {
</div>
</div>
{/* Actions Menu Placeholder */}
{
/* <div className="absolute top-1 right-1">
{/* <div className="absolute top-1 right-1">
<MessageActionsMenu onReply={() => console.log("Reply")} />
</div> */
}
</div> */}
</li>
);
};
@@ -1,9 +1,9 @@
import { beforeEach, describe, expect, it, vi } 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 { mockDeviceStore } from "@core/stores/deviceStore.mock.ts";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core";
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@core/stores/deviceStore");
@@ -18,7 +18,7 @@ describe("TraceRoute", () => {
macaddr: new Uint8Array([0x01, 0x02, 0x03, 0x04]),
id: "source-node",
isLicensed: false,
role: Protobuf.Config.Config_DeviceConfig_Role["CLIENT"],
role: Protobuf.Config.Config_DeviceConfig_Role.CLIENT,
} as Protobuf.Mesh.NodeInfo["user"],
};
@@ -32,7 +32,7 @@ describe("TraceRoute", () => {
macaddr: new Uint8Array([0x05, 0x06, 0x07, 0x08]),
id: "destination-node",
isLicensed: false,
role: Protobuf.Config.Config_DeviceConfig_Role["CLIENT"],
role: Protobuf.Config.Config_DeviceConfig_Role.CLIENT,
} as Protobuf.Mesh.NodeInfo["user"],
};
@@ -123,13 +123,7 @@ describe("TraceRoute", () => {
});
it("renders '??' for missing SNR values", () => {
render(
<TraceRoute
from={fromUser}
to={toUser}
route={[1]}
/>,
);
render(<TraceRoute from={fromUser} to={toUser} route={[1]} />);
expect(screen.getByText("Node A")).toBeInTheDocument();
// Check for translated '??' placeholder
@@ -22,9 +22,7 @@ interface RoutePathProps {
snr?: number[];
}
const RoutePath = (
{ title, from, to, path, snr }: RoutePathProps,
) => {
const RoutePath = ({ title, from, to, path, snr }: RoutePathProps) => {
const { getNode } = useDevice();
const { t } = useTranslation();
@@ -1,4 +1,4 @@
import { useDevice } from "@core/stores/deviceStore.ts";
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import {
type AmbientLightingValidation,
AmbientLightingValidationSchema,
@@ -8,17 +8,19 @@ import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface AmbientLightingModuleConfigProps {
onFormInit: DynamicFormFormInit<AmbientLightingValidation>;
}
export const AmbientLighting = (
{ onFormInit }: AmbientLightingModuleConfigProps,
) => {
export const AmbientLighting = ({
onFormInit,
}: AmbientLightingModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "ambientLighting" });
const {
moduleConfig,
setWorkingModuleConfig,
@@ -1,3 +1,4 @@
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import {
type AudioValidation,
AudioValidationSchema,
@@ -8,15 +9,16 @@ import {
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface AudioModuleConfigProps {
onFormInit: DynamicFormFormInit<AudioValidation>;
}
export const Audio = ({ onFormInit }: AudioModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "audio" });
const {
moduleConfig,
setWorkingModuleConfig,
@@ -1,3 +1,4 @@
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import {
type CannedMessageValidation,
CannedMessageValidationSchema,
@@ -8,17 +9,19 @@ import {
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface CannedMessageModuleConfigProps {
onFormInit: DynamicFormFormInit<CannedMessageValidation>;
}
export const CannedMessage = (
{ onFormInit }: CannedMessageModuleConfigProps,
) => {
export const CannedMessage = ({
onFormInit,
}: CannedMessageModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "cannedMessage" });
const {
moduleConfig,
setWorkingModuleConfig,
@@ -92,8 +95,9 @@ export const CannedMessage = (
label: t("cannedMessage.inputbrokerEventCw.label"),
description: t("cannedMessage.inputbrokerEventCw.description"),
properties: {
enumValue: Protobuf.ModuleConfig
.ModuleConfig_CannedMessageConfig_InputEventChar,
enumValue:
Protobuf.ModuleConfig
.ModuleConfig_CannedMessageConfig_InputEventChar,
},
},
{
@@ -102,8 +106,9 @@ export const CannedMessage = (
label: t("cannedMessage.inputbrokerEventCcw.label"),
description: t("cannedMessage.inputbrokerEventCcw.description"),
properties: {
enumValue: Protobuf.ModuleConfig
.ModuleConfig_CannedMessageConfig_InputEventChar,
enumValue:
Protobuf.ModuleConfig
.ModuleConfig_CannedMessageConfig_InputEventChar,
},
},
{
@@ -112,8 +117,9 @@ export const CannedMessage = (
label: t("cannedMessage.inputbrokerEventPress.label"),
description: t("cannedMessage.inputbrokerEventPress.description"),
properties: {
enumValue: Protobuf.ModuleConfig
.ModuleConfig_CannedMessageConfig_InputEventChar,
enumValue:
Protobuf.ModuleConfig
.ModuleConfig_CannedMessageConfig_InputEventChar,
},
},
{
@@ -1,4 +1,4 @@
import { useDevice } from "@core/stores/deviceStore.ts";
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import {
type DetectionSensorValidation,
DetectionSensorValidationSchema,
@@ -8,17 +8,20 @@ import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface DetectionSensorModuleConfigProps {
onFormInit: DynamicFormFormInit<DetectionSensorValidation>;
}
export const DetectionSensor = (
{ onFormInit }: DetectionSensorModuleConfigProps,
) => {
export const DetectionSensor = ({
onFormInit,
}: DetectionSensorModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "detectionSensor" });
const {
moduleConfig,
setWorkingModuleConfig,
@@ -135,8 +138,9 @@ export const DetectionSensor = (
},
],
properties: {
enumValue: Protobuf.ModuleConfig
.ModuleConfig_DetectionSensorConfig_TriggerType,
enumValue:
Protobuf.ModuleConfig
.ModuleConfig_DetectionSensorConfig_TriggerType,
},
},
{
@@ -1,3 +1,4 @@
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import {
type ExternalNotificationValidation,
ExternalNotificationValidationSchema,
@@ -8,17 +9,19 @@ import {
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface ExternalNotificationModuleConfigProps {
onFormInit: DynamicFormFormInit<ExternalNotificationValidation>;
}
export const ExternalNotification = (
{ onFormInit }: ExternalNotificationModuleConfigProps,
) => {
export const ExternalNotification = ({
onFormInit,
}: ExternalNotificationModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "externalNotification" });
const {
moduleConfig,
setWorkingModuleConfig,
@@ -1,4 +1,4 @@
import { useDevice } from "@core/stores/deviceStore.ts";
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import {
type MqttValidation,
MqttValidationSchema,
@@ -8,15 +8,18 @@ import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface MqttModuleConfigProps {
onFormInit: DynamicFormFormInit<MqttValidation>;
}
export const MQTT = ({ onFormInit }: MqttModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "mqtt" });
const {
config,
moduleConfig,
@@ -55,10 +58,12 @@ export const MQTT = ({ onFormInit }: MqttModuleConfigProps) => {
) => {
return cfg
? {
...cfg,
mapReportSettings: cfg.mapReportSettings ??
{ publishIntervalSecs: 0, positionPrecision: 10 },
}
...cfg,
mapReportSettings: cfg.mapReportSettings ?? {
publishIntervalSecs: 0,
positionPrecision: 10,
},
}
: undefined;
};
@@ -202,78 +207,77 @@ export const MQTT = ({ onFormInit }: MqttModuleConfigProps) => {
{
type: "select",
name: "mapReportSettings.positionPrecision",
label: t(
"mqtt.mapReportSettings.positionPrecision.label",
),
label: t("mqtt.mapReportSettings.positionPrecision.label"),
description: t(
"mqtt.mapReportSettings.positionPrecision.description",
),
properties: {
enumValue: config.display?.units === 0
? {
[
t("mqtt.mapReportSettings.positionPrecision.options.metric_km23")
]: 10,
[
t("mqtt.mapReportSettings.positionPrecision.options.metric_km12")
]: 11,
[
t("mqtt.mapReportSettings.positionPrecision.options.metric_km5_8")
]: 12,
[
t("mqtt.mapReportSettings.positionPrecision.options.metric_km2_9")
]: 13,
[
t("mqtt.mapReportSettings.positionPrecision.options.metric_km1_5")
]: 14,
[
t("mqtt.mapReportSettings.positionPrecision.options.metric_m700")
]: 15,
[
t("mqtt.mapReportSettings.positionPrecision.options.metric_m350")
]: 16,
[
t("mqtt.mapReportSettings.positionPrecision.options.metric_m200")
]: 17,
[
t("mqtt.mapReportSettings.positionPrecision.options.metric_m90")
]: 18,
[
t("mqtt.mapReportSettings.positionPrecision.options.metric_m50")
]: 19,
}
: {
[
t("mqtt.mapReportSettings.positionPrecision.options.imperial_mi15")
]: 10,
[
t("mqtt.mapReportSettings.positionPrecision.options.imperial_mi7_3")
]: 11,
[
t("mqtt.mapReportSettings.positionPrecision.options.imperial_mi3_6")
]: 12,
[
t("mqtt.mapReportSettings.positionPrecision.options.imperial_mi1_8")
]: 13,
[
t("mqtt.mapReportSettings.positionPrecision.options.imperial_mi0_9")
]: 14,
[
t("mqtt.mapReportSettings.positionPrecision.options.imperial_mi0_5")
]: 15,
[
t("mqtt.mapReportSettings.positionPrecision.options.imperial_mi0_2")
]: 16,
[
t("mqtt.mapReportSettings.positionPrecision.options.imperial_ft600")
]: 17,
[
t("mqtt.mapReportSettings.positionPrecision.options.imperial_ft300")
]: 18,
[
t("mqtt.mapReportSettings.positionPrecision.options.imperial_ft150")
]: 19,
},
enumValue:
config.display?.units === 0
? {
[t(
"mqtt.mapReportSettings.positionPrecision.options.metric_km23",
)]: 10,
[t(
"mqtt.mapReportSettings.positionPrecision.options.metric_km12",
)]: 11,
[t(
"mqtt.mapReportSettings.positionPrecision.options.metric_km5_8",
)]: 12,
[t(
"mqtt.mapReportSettings.positionPrecision.options.metric_km2_9",
)]: 13,
[t(
"mqtt.mapReportSettings.positionPrecision.options.metric_km1_5",
)]: 14,
[t(
"mqtt.mapReportSettings.positionPrecision.options.metric_m700",
)]: 15,
[t(
"mqtt.mapReportSettings.positionPrecision.options.metric_m350",
)]: 16,
[t(
"mqtt.mapReportSettings.positionPrecision.options.metric_m200",
)]: 17,
[t(
"mqtt.mapReportSettings.positionPrecision.options.metric_m90",
)]: 18,
[t(
"mqtt.mapReportSettings.positionPrecision.options.metric_m50",
)]: 19,
}
: {
[t(
"mqtt.mapReportSettings.positionPrecision.options.imperial_mi15",
)]: 10,
[t(
"mqtt.mapReportSettings.positionPrecision.options.imperial_mi7_3",
)]: 11,
[t(
"mqtt.mapReportSettings.positionPrecision.options.imperial_mi3_6",
)]: 12,
[t(
"mqtt.mapReportSettings.positionPrecision.options.imperial_mi1_8",
)]: 13,
[t(
"mqtt.mapReportSettings.positionPrecision.options.imperial_mi0_9",
)]: 14,
[t(
"mqtt.mapReportSettings.positionPrecision.options.imperial_mi0_5",
)]: 15,
[t(
"mqtt.mapReportSettings.positionPrecision.options.imperial_mi0_2",
)]: 16,
[t(
"mqtt.mapReportSettings.positionPrecision.options.imperial_ft600",
)]: 17,
[t(
"mqtt.mapReportSettings.positionPrecision.options.imperial_ft300",
)]: 18,
[t(
"mqtt.mapReportSettings.positionPrecision.options.imperial_ft150",
)]: 19,
},
},
disabledBy: [
{
@@ -1,4 +1,4 @@
import { useDevice } from "@core/stores/deviceStore.ts";
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import {
type NeighborInfoValidation,
NeighborInfoValidationSchema,
@@ -8,15 +8,18 @@ import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface NeighborInfoModuleConfigProps {
onFormInit: DynamicFormFormInit<NeighborInfoValidation>;
}
export const NeighborInfo = ({ onFormInit }: NeighborInfoModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "neighborInfo" });
const {
moduleConfig,
setWorkingModuleConfig,
@@ -1,3 +1,4 @@
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import {
type PaxcounterValidation,
PaxcounterValidationSchema,
@@ -8,15 +9,17 @@ import {
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface PaxcounterModuleConfigProps {
onFormInit: DynamicFormFormInit<PaxcounterValidation>;
}
export const Paxcounter = ({ onFormInit }: PaxcounterModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "paxcounter" });
const {
moduleConfig,
setWorkingModuleConfig,
@@ -1,3 +1,4 @@
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import {
type RangeTestValidation,
RangeTestValidationSchema,
@@ -8,21 +9,24 @@ import {
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface RangeTestModuleConfigProps {
onFormInit: DynamicFormFormInit<RangeTestValidation>;
}
export const RangeTest = ({ onFormInit }: RangeTestModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "rangeTest" });
const {
moduleConfig,
setWorkingModuleConfig,
getEffectiveModuleConfig,
removeWorkingModuleConfig,
} = useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: RangeTestValidation) => {
@@ -1,3 +1,4 @@
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import {
type SerialValidation,
SerialValidationSchema,
@@ -8,15 +9,17 @@ import {
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface SerialModuleConfigProps {
onFormInit: DynamicFormFormInit<SerialValidation>;
}
export const Serial = ({ onFormInit }: SerialModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "serial" });
const {
moduleConfig,
setWorkingModuleConfig,
@@ -1,3 +1,4 @@
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import {
type StoreForwardValidation,
StoreForwardValidationSchema,
@@ -8,15 +9,17 @@ import {
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface StoreForwardModuleConfigProps {
onFormInit: DynamicFormFormInit<StoreForwardValidation>;
}
export const StoreForward = ({ onFormInit }: StoreForwardModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "storeForward" });
const {
moduleConfig,
setWorkingModuleConfig,
@@ -1,3 +1,4 @@
import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import {
type TelemetryValidation,
TelemetryValidationSchema,
@@ -8,15 +9,17 @@ import {
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface TelemetryModuleConfigProps {
onFormInit: DynamicFormFormInit<TelemetryValidation>;
}
export const Telemetry = ({ onFormInit }: TelemetryModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "telemetry" });
const {
moduleConfig,
setWorkingModuleConfig,
+7 -5
View File
@@ -1,10 +1,10 @@
import React from "react";
import { cn } from "@core/utils/cn.ts";
import { type LucideIcon } from "lucide-react";
import { ErrorPage } from "@components/UI/ErrorPage.tsx";
import Footer from "@components/UI/Footer.tsx";
import { Spinner } from "@components/UI/Spinner.tsx";
import { cn } from "@core/utils/cn.ts";
import type { LucideIcon } from "lucide-react";
import type React from "react";
import { ErrorBoundary } from "react-error-boundary";
import { ErrorPage } from "@components/UI/ErrorPage.tsx";
export interface ActionItem {
key: string;
@@ -90,7 +90,9 @@ export const PageLayout = ({
aria-busy={action.isLoading}
>
{action.icon &&
(action.isLoading ? <Spinner size="md" /> : (
(action.isLoading ? (
<Spinner size="md" />
) : (
<action.icon
className={cn("h-5 w-5", action.iconClasses)}
/>
+41 -43
View File
@@ -1,11 +1,13 @@
import type React from "react";
import { useEffect, useState, useTransition } from "react";
import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.tsx";
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.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 { Subtle } from "@components/UI/Typography/Subtle.tsx";
import { useAppStore } from "@core/stores/appStore.ts";
import type { Page } from "@core/stores/deviceStore.ts";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useSidebar } from "@core/stores/sidebarStore.tsx";
import { cn } from "@core/utils/cn.ts";
import { useLocation, useNavigate } from "@tanstack/react-router";
import {
CircleChevronLeft,
LayersIcon,
@@ -15,13 +17,10 @@ import {
SettingsIcon,
UsersIcon,
} from "lucide-react";
import { cn } from "@core/utils/cn.ts";
import { useSidebar } from "@core/stores/sidebarStore.tsx";
import { useAppStore } from "@core/stores/appStore.ts";
import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.tsx";
import type React from "react";
import { useEffect, useState, useTransition } from "react";
import { useTranslation } from "react-i18next";
import { DeviceInfoPanel } from "./DeviceInfoPanel.tsx";
import { useLocation, useNavigate } from "@tanstack/react-router";
export interface SidebarProps {
children?: React.ReactNode;
@@ -89,7 +88,7 @@ export const Sidebar = ({ children }: SidebarProps) => {
const numUnread = [...unreadCounts.values()].reduce((sum, v) => sum + v, 0);
const [displayedNodeCount, setDisplayedNodeCount] = useState(() =>
Math.max(getNodesLength() - 1, 0)
Math.max(getNodesLength() - 1, 0),
);
const [_, startNodeCountTransition] = useTransition();
@@ -189,37 +188,36 @@ export const Sidebar = ({ children }: SidebarProps) => {
</div>
<div className=" pt-4 border-t-[0.5px] bg-background-primary border-slate-300 dark:border-slate-700 h-full flex-1">
{myNode === undefined
? (
<div className="flex flex-col items-center justify-center py-6">
<Spinner />
<Subtle
className={cn(
"mt-4 transition-opacity duration-300",
isCollapsed ? "opacity-0 invisible" : "opacity-100 visible",
)}
>
{t("loading")}
</Subtle>
</div>
)
: (
<DeviceInfoPanel
isCollapsed={isCollapsed}
setCommandPaletteOpen={() => setCommandPaletteOpen(true)}
setDialogOpen={() => setDialogOpen("deviceName", true)}
user={{
longName: myNode?.user?.longName ?? t("unknown.longName"),
shortName: myNode?.user?.shortName ?? t("unknown.shortName"),
}}
firmwareVersion={myMetadata?.firmwareVersion ??
t("unknown.notAvailable")}
deviceMetrics={{
batteryLevel: myNode.deviceMetrics?.batteryLevel,
voltage: myNode.deviceMetrics?.voltage,
}}
/>
)}
{myNode === undefined ? (
<div className="flex flex-col items-center justify-center py-6">
<Spinner />
<Subtle
className={cn(
"mt-4 transition-opacity duration-300",
isCollapsed ? "opacity-0 invisible" : "opacity-100 visible",
)}
>
{t("loading")}
</Subtle>
</div>
) : (
<DeviceInfoPanel
isCollapsed={isCollapsed}
setCommandPaletteOpen={() => setCommandPaletteOpen(true)}
setDialogOpen={() => setDialogOpen("deviceName", true)}
user={{
longName: myNode?.user?.longName ?? t("unknown.longName"),
shortName: myNode?.user?.shortName ?? t("unknown.shortName"),
}}
firmwareVersion={
myMetadata?.firmwareVersion ?? t("unknown.notAvailable")
}
deviceMetrics={{
batteryLevel: myNode.deviceMetrics?.batteryLevel,
voltage: myNode.deviceMetrics?.voltage,
}}
/>
)}
</div>
</div>
);
@@ -2,8 +2,8 @@ import { useTheme } from "@core/hooks/useTheme.ts";
import { cn } from "@core/utils/cn.ts";
import { Monitor, Moon, Sun } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Subtle } from "./UI/Typography/Subtle.tsx";
import { Button } from "./UI/Button.tsx";
import { Subtle } from "./UI/Typography/Subtle.tsx";
type ThemePreference = "light" | "dark" | "system";
+40 -95
View File
@@ -1,5 +1,4 @@
import { cn } from "@core/utils/cn.ts";
import { LockKeyholeOpenIcon, StarIcon } from "lucide-react";
import { getColorFromText, isLightColor } from "@app/core/utils/color";
import {
Tooltip,
TooltipArrow,
@@ -7,15 +6,10 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@components/UI/Tooltip.tsx";
import { cn } from "@core/utils/cn.ts";
import { LockKeyholeOpenIcon, StarIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
type RGBColor = {
r: number;
g: number;
b: number;
a: number;
};
interface AvatarProps {
text: string | number;
size?: "sm" | "lg";
@@ -24,49 +18,6 @@ interface AvatarProps {
showFavorite?: boolean;
}
class ColorUtils {
static hexToRgb(hex: number): RGBColor {
return {
r: (hex & 0xff0000) >> 16,
g: (hex & 0x00ff00) >> 8,
b: hex & 0x0000ff,
a: 255,
};
}
static rgbToHex(color: RGBColor): number {
return (
(Math.round(color.a) << 24) |
(Math.round(color.r) << 16) |
(Math.round(color.g) << 8) |
Math.round(color.b)
);
}
static isLight(color: RGBColor): boolean {
const brightness = (color.r * 299 + color.g * 587 + color.b * 114) / 1000;
return brightness > 127.5;
}
}
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",
@@ -83,7 +34,7 @@ export const Avatar = ({
const safeText = text?.toString().toUpperCase();
const bgColor = getColorFromText(safeText);
const isLight = ColorUtils.isLight(bgColor);
const isLight = isLightColor(bgColor);
const textColor = isLight ? "#000000" : "#FFFFFF";
const initials = safeText?.slice(0, 4) ?? t("unknown.shortName");
@@ -100,48 +51,42 @@ export const Avatar = ({
color: textColor,
}}
>
{showFavorite
? (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<StarIcon
className="absolute -top-0.5 -right-0.5 z-10 size-4 stroke-1 fill-yellow-400"
aria-hidden="true"
style={{
color: `rgb(${bgColor.r}, ${bgColor.g}, ${bgColor.b})`,
}}
/>
</TooltipTrigger>
<TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs">
{t("nodeDetail.favorite.label", { ns: "nodes" })}
<TooltipArrow className="fill-slate-800 dark:fill-slate-600" />
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
: null}
{showError
? (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<LockKeyholeOpenIcon
className="absolute -bottom-0.5 -right-0.5 z-10 size-4 text-red-500 stroke-3"
aria-hidden="true"
/>
</TooltipTrigger>
<TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs">
{t("nodeDetail.error.label", { ns: "nodes" })}
<TooltipArrow className="fill-slate-800 dark:fill-slate-600" />
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
: null}
<p className="p-1 text-nowrap">
{initials}
</p>
{showFavorite ? (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<StarIcon
className="absolute -top-0.5 -right-0.5 z-10 size-4 stroke-1 fill-yellow-400"
aria-hidden="true"
style={{
color: `rgb(${bgColor.r}, ${bgColor.g}, ${bgColor.b})`,
}}
/>
</TooltipTrigger>
<TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs">
{t("nodeDetail.favorite.label", { ns: "nodes" })}
<TooltipArrow className="fill-slate-800 dark:fill-slate-600" />
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : null}
{showError ? (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<LockKeyholeOpenIcon
className="absolute -bottom-0.5 -right-0.5 z-10 size-4 text-red-500 stroke-3"
aria-hidden="true"
/>
</TooltipTrigger>
<TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs">
{t("nodeDetail.error.label", { ns: "nodes" })}
<TooltipArrow className="fill-slate-800 dark:fill-slate-600" />
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : null}
<p className="p-1 text-nowrap">{initials}</p>
</div>
);
};
+4 -6
View File
@@ -1,6 +1,6 @@
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "@core/utils/cn.ts";
import { cva, type VariantProps } from "class-variance-authority";
import type * as React from "react";
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:opacity-50 dark:focus:ring-slate-400 disabled:cursor-not-allowed dark:focus:ring-offset-slate-900 cursor-pointer",
@@ -19,8 +19,7 @@ const buttonVariants = cva(
"bg-slate-100 text-slate-700 hover:bg-slate-200 dark:bg-slate-500 dark:text-white dark:hover:bg-slate-400",
ghost:
"bg-transparent hover:bg-slate-100 dark:hover:bg-slate-800 dark:text-slate-100 dark:hover:text-slate-100 data-[state=open]:bg-transparent dark:data-[state=open]:bg-transparent",
link:
"bg-transparent underline-offset-4 hover:underline text-slate-900 dark:text-slate-100 hover:bg-transparent dark:hover:bg-transparent",
link: "bg-transparent underline-offset-4 hover:underline text-slate-900 dark:text-slate-100 hover:bg-transparent dark:hover:bg-transparent",
},
size: {
default: "h-10 py-2 px-4",
@@ -39,8 +38,7 @@ const buttonVariants = cva(
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
export interface ButtonProps
extends
React.ButtonHTMLAttributes<HTMLButtonElement>,
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
icon?: React.ReactNode;
iconAlignment?: "left" | "right";
@@ -1,17 +1,20 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { Checkbox } from "@components/UI/Checkbox/index.tsx";
import React from "react";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import type React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@components/UI/Label.tsx", () => ({
Label: (
{ children, className, htmlFor, id }: {
children: React.ReactNode;
className: string;
htmlFor: string;
id: string;
},
) => (
Label: ({
children,
className,
htmlFor,
id,
}: {
children: React.ReactNode;
className: string;
htmlFor: string;
id: string;
}) => (
<label
data-testid="label-component"
className={className}
@@ -1,7 +1,6 @@
import { useEffect, useId, useState } from "react";
import { Check } from "lucide-react";
import { Label } from "@components/UI/Label.tsx";
import { cn } from "@core/utils/cn.ts";
import { Check } from "lucide-react";
import { useId } from "react";
interface CheckboxProps {
checked?: boolean;
@@ -16,10 +15,9 @@ interface CheckboxProps {
}
export function Checkbox({
checked,
checked = false,
onChange,
className,
labelClassName,
id: propId,
children,
disabled = false,
@@ -30,71 +28,49 @@ export function Checkbox({
const generatedId = useId();
const id = propId || generatedId;
const [isChecked, setIsChecked] = useState(checked || false);
// Make sure setIsChecked state updates with checked
useEffect(() => {
setIsChecked(checked || false);
}, [checked]);
const handleToggle = () => {
if (disabled) return;
const newChecked = !isChecked;
setIsChecked(newChecked);
onChange?.(newChecked);
const handleToggle = (): void => {
if (!disabled) {
onChange?.(!checked);
}
};
return (
<div className={cn("flex items-center", className)}>
<div className="relative flex items-start">
<div className="flex items-center h-5">
<input
type="checkbox"
id={id}
checked={isChecked}
onChange={handleToggle}
disabled={disabled}
required={required}
name={name}
className="sr-only"
{...rest}
/>
<div
onClick={handleToggle}
role="presentation"
className={cn(
"w-6 h-6 border-2 border-gray-500 rounded-md flex items-center justify-center",
disabled
? "opacity-50 cursor-not-allowed"
: "cursor-pointer focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2",
isChecked ? "" : "",
)}
>
{isChecked && (
<div className="animate-fade-in scale-100 opacity-100">
<Check className="w-4 h-4" />
</div>
)}
</div>
</div>
{children && (
<div className="ml-3 text-sm">
<Label
htmlFor={id}
id={`${id}-label`}
className={cn(
"text-gray-900 dark:text-gray-900",
disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer",
labelClassName,
)}
>
{children}
</Label>
<label
className={cn(
"inline-flex items-center gap-3",
disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer",
className,
)}
>
<input
type="checkbox"
id={id}
checked={checked}
onChange={handleToggle}
disabled={disabled}
required={required}
name={name}
className="sr-only peer"
{...rest}
/>
<div
className={cn(
"flex h-6 w-6 shrink-0 items-center justify-center rounded-md border-2 border-gray-500 transition-colors",
"peer-focus-visible:ring-2 peer-focus-visible:ring-blue-500 peer-focus-visible:ring-offset-2",
{ "border-slate-500 bg-slate-500": checked },
)}
>
{checked && (
<div className="animate-fade-in">
<Check className="h-4 w-4 text-white" />
</div>
)}
</div>
</div>
{children && (
<span className="text-sm text-gray-900 dark:text-gray-100">
{children}
</span>
)}
</label>
);
}
+2 -3
View File
@@ -1,11 +1,10 @@
import { Dialog, DialogContent } from "@components/UI/Dialog.tsx";
import { cn } from "@core/utils/cn.ts";
import type { DialogProps } from "@radix-ui/react-dialog";
import { Command as CommandPrimitive } from "cmdk";
import { Search } from "lucide-react";
import * as React from "react";
import { Dialog, DialogContent } from "@components/UI/Dialog.tsx";
import { cn } from "@core/utils/cn.ts";
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
+1 -2
View File
@@ -1,9 +1,8 @@
import { cn } from "@core/utils/cn.ts";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import * as React from "react";
import { cn } from "@core/utils/cn.ts";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
@@ -1,9 +1,8 @@
import { cn } from "@core/utils/cn.ts";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import * as React from "react";
import { cn } from "@core/utils/cn.ts";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
+6 -10
View File
@@ -1,17 +1,17 @@
import newGithubIssueUrl from "@core/utils/github.ts";
import { ExternalLink } from "lucide-react";
import { Heading } from "@components/UI/Typography/Heading.tsx";
import { Link } from "@components/UI/Typography/Link.tsx";
import { P } from "@components/UI/Typography/P.tsx";
import newGithubIssueUrl from "@core/utils/github.ts";
import { ExternalLink } from "lucide-react";
import { Trans, useTranslation } from "react-i18next";
export function ErrorPage({ error }: { error: Error }) {
const { t } = useTranslation();
if (!error) {
return null;
}
const { t } = useTranslation();
return (
<article className="w-full h-screen overflow-y-auto bg-background-primary text-text-primary">
<section className="flex shrink md:flex-row gap-16 mt-20 px-4 md:px-8 text-lg md:text-xl space-y-2 place-items-center dark:bg-background-primary text-slate-900 dark:text-text-primary">
@@ -19,12 +19,8 @@ export function ErrorPage({ error }: { error: Error }) {
<Heading as="h2" className="text-text-primary">
{t("errorPage.title")}
</Heading>
<P>
{t("errorPage.description1")}
</P>
<P>
{t("errorPage.description2")}
</P>
<P>{t("errorPage.description1")}</P>
<P>{t("errorPage.description2")}</P>
<P>Please include the following information in your report:</P>
<ul className="list-disc list-inside text-sm">
<li>{t("errorPage.reportSteps.step1")}</li>
@@ -27,12 +27,14 @@ const Footer = ({ className, ...props }: FooterProps) => {
<Trans
i18nKey="footer.text"
components={[
// biome-ignore lint/a11y/useAnchorContent: because I want to use the link component
<a
key="vercel"
rel="noopener noreferrer"
href="https://vercel.com/?utm_source=meshtastic&utm_campaign=oss"
className="hover:underline text-link"
/>,
// biome-ignore lint/a11y/useAnchorContent: because I want to use the link component
<a
key="legal"
rel="noopener noreferrer"
+46 -42
View File
@@ -1,4 +1,3 @@
import { useEffect, useRef } from "react";
import { Button, type ButtonVariant } from "@components/UI/Button.tsx";
import { Input } from "@components/UI/Input.tsx";
import {
@@ -8,6 +7,7 @@ import {
SelectTrigger,
SelectValue,
} from "@components/UI/Select.tsx";
import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
export interface ActionButton {
@@ -33,58 +33,62 @@ export interface GeneratorProps extends React.BaseHTMLAttributes<HTMLElement> {
disabled?: boolean;
}
const Generator = (
{
type,
devicePSKBitCount,
id = "pskInput",
variant,
value,
actionButtons,
bits,
selectChange,
inputChange,
disabled,
showPasswordToggle,
showCopyButton,
...props
}: GeneratorProps,
) => {
const Generator = ({
type,
devicePSKBitCount,
id = "pskInput",
variant,
value,
actionButtons,
bits,
selectChange,
inputChange,
disabled,
showPasswordToggle,
showCopyButton,
...props
}: GeneratorProps) => {
const inputRef = useRef<HTMLInputElement>(null);
const { t } = useTranslation();
const passwordRequiredBitSize = bits ? bits : [
{
text: t("security.256bit"),
value: "32",
key: "bit256",
},
{
text: t("security.128bit"),
value: "16",
key: "bit128",
},
{
text: t("security.8bit"),
value: "1",
key: "bit8",
},
{
text: t("security.0bit"),
value: "0",
key: "bit0",
},
];
const passwordRequiredBitSize = bits
? bits
: [
{
text: t("security.256bit"),
value: "32",
key: "bit256",
},
{
text: t("security.128bit"),
value: "16",
key: "bit128",
},
{
text: t("security.8bit"),
value: "1",
key: "bit8",
},
{
text: t("security.0bit"),
value: "0",
key: "bit0",
},
];
// Invokes onChange event on the input element when the value changes from the parent component
useEffect(() => {
if (!inputRef.current) return;
if (!inputRef.current) {
return;
}
const setValue = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
"value",
)?.set;
if (!setValue) return;
if (!setValue) {
return;
}
inputRef.current.value = "";
setValue.call(inputRef.current, value);
inputRef.current.dispatchEvent(new Event("input", { bubbles: true }));
+12 -7
View File
@@ -1,9 +1,9 @@
import * as React from "react";
import { useCopyToClipboard } from "@core/hooks/useCopyToClipboard.ts";
import { usePasswordVisibilityToggle } from "@core/hooks/usePasswordVisibilityToggle.ts";
import { cn } from "@core/utils/cn.ts";
import { cva, type VariantProps } from "class-variance-authority";
import { Check, Copy, Eye, EyeOff, type LucideIcon, X } from "lucide-react";
import { useCopyToClipboard } from "@core/hooks/useCopyToClipboard.ts";
import { usePasswordVisibilityToggle } from "@core/hooks/usePasswordVisibilityToggle.ts";
import * as React from "react";
import { useTranslation } from "react-i18next";
const cnInvalidBase = "border-2 border-red-500 dark:border-red-500";
@@ -35,8 +35,10 @@ type InputActionType = {
};
export interface InputProps
extends
Omit<React.InputHTMLAttributes<HTMLInputElement>, "prefix" | "suffix">,
extends Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"prefix" | "suffix"
>,
VariantProps<typeof inputVariants> {
prefix?: React.ReactNode;
suffix?: React.ReactNode;
@@ -127,7 +129,9 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
const actions = potentialActions.filter((action) => action.condition);
const inputType = showPasswordToggle
? (isVisible ? "text" : "password")
? isVisible
? "text"
: "password"
: type;
const hasPrefix = !!prefix;
@@ -197,7 +201,8 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
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 last:hover:rounded-r-md last:dark:hover:rounded-r-md",
disabled && "text-slate-300 dark:text-slate-600",
action.id === "copy-value" && isCopied &&
action.id === "copy-value" &&
isCopied &&
"text-green-600 dark:text-green-500",
)}
onClick={action.onClick}
+1 -2
View File
@@ -1,8 +1,7 @@
import { cn } from "@core/utils/cn.ts";
import * as LabelPrimitive from "@radix-ui/react-label";
import * as React from "react";
import { cn } from "@core/utils/cn.ts";
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
+1 -2
View File
@@ -1,9 +1,8 @@
import { cn } from "@core/utils/cn.ts";
import * as MenubarPrimitive from "@radix-ui/react-menubar";
import { Check, ChevronRight, Circle } from "lucide-react";
import * as React from "react";
import { cn } from "@core/utils/cn.ts";
const MenubarMenu = MenubarPrimitive.Menu;
const MenubarGroup = MenubarPrimitive.Group;
@@ -1,6 +1,6 @@
import { cn } from "../../core/utils/cn.ts";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react";
import { cn } from "../../core/utils/cn.ts";
interface MultiSelectProps {
children: React.ReactNode;
@@ -8,8 +8,9 @@ interface MultiSelectProps {
}
const MultiSelect = ({ children, className = "" }: MultiSelectProps) => {
return <div className={cn("flex flex-wrap gap-2", className)}>{children}
</div>;
return (
<div className={cn("flex flex-wrap gap-2", className)}>{children}</div>
);
};
interface MultiSelectItemProps {
+1 -2
View File
@@ -1,8 +1,7 @@
import { cn } from "@core/utils/cn.ts";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import * as React from "react";
import { cn } from "@core/utils/cn.ts";
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
@@ -1,8 +1,7 @@
import { cn } from "@core/utils/cn.ts";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import * as React from "react";
import { cn } from "@core/utils/cn.ts";
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
+1 -2
View File
@@ -1,9 +1,8 @@
import { cn } from "@core/utils/cn.ts";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown } from "lucide-react";
import * as React from "react";
import { cn } from "@core/utils/cn.ts";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
+1 -2
View File
@@ -1,8 +1,7 @@
import { cn } from "@core/utils/cn.ts";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import * as React from "react";
import { cn } from "@core/utils/cn.ts";
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>

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